Jelajahi Sumber

feat(tihao): 题号进度看板 + 技能包进度上报

- 云函数 tihao-task-progress 已部署 [report/board/get/list/delete]
- 技能包 reporter: 生命周期上报 + brief/结果CSV/报告 直传七牛 [09-uploadManager]
- 前端: 公司登录 [Parse login] + 人×任务×进度看板 [5s 轮询, artifact 链接]
Devin 2 bulan lalu
induk
melakukan
223cc7040e

+ 295 - 0
claude-code-tihao-sourcing/mcp/src/features/tihao-sourcing/progress-reporter.js

@@ -0,0 +1,295 @@
+'use strict';
+
+/**
+ * 题号任务进度上报器(live 模式生命周期打点 + 七牛产物上传)。
+ *
+ * - 上报目标:FMode 云函数 tihao-task-progress(action=report),看板据此聚合「人×任务×进度」。
+ * - 产物:原始 brief(文件原样或文本转 .md)与最终结果 CSV,经 09-uploadManager 直传七牛,URL 落到任务记录。
+ * - 全程静默容错:拿不到 sessionToken、上传失败、上报失败都不抛错、不阻断出名单。
+ *
+ * 关闭:input.reportProgress === false / input.noProgress === true / 环境变量 TIHAO_PROGRESS_DISABLED=1。
+ */
+
+const fs = require('fs');
+const path = require('path');
+const { readTihaoToken } = require('../../core/credentials');
+
+const PARSE_HOST = (env('TIHAO_PROGRESS_HOST') || env('PARSE_BASE_URL') || 'https://server.fmode.cn').replace(/\/+$/, '');
+const FUNCTIONS_URL = `${PARSE_HOST}/api/functions`;
+const PARSE_APP_ID = env('TIHAO_PARSE_APP_ID') || env('PARSE_APP_ID') || 'ncloudmaster';
+const PROGRESS_FN_ID = env('TIHAO_PROGRESS_FN_ID') || 'WrdzmO5fqr';
+const UPLOAD_FN_ID = env('TIHAO_UPLOAD_FN_ID') || 'VHS0noRP7Q'; // 09-uploadManager
+const BRIEF_PREVIEW_LIMIT = 4000;
+const CALL_TIMEOUT_MS = 20000;
+const CALL_RETRIES = 6;
+
+function env(name) {
+  return typeof process !== 'undefined' && process.env ? process.env[name] : undefined;
+}
+
+function isEnabled(input) {
+  if (input && (input.reportProgress === false || input.noProgress === true)) return false;
+  if (env('TIHAO_PROGRESS_DISABLED') === '1') return false;
+  return true;
+}
+
+function createReporter(input = {}, context = {}) {
+  const sessionToken = isEnabled(input) ? String(readTihaoToken(input) || '').trim() : '';
+  const outputDir = context.outputDir || input.output || input.outputDir || '';
+  const taskId = String(
+    input.taskId || input.progressTaskId ||
+    (outputDir ? path.basename(outputDir) : '') ||
+    `tihao-${Date.now()}`
+  ).trim();
+  const active = Boolean(sessionToken);
+  return new ProgressReporter({ sessionToken, taskId, active });
+}
+
+class ProgressReporter {
+  constructor({ sessionToken, taskId, active }) {
+    this.sessionToken = sessionToken;
+    this.taskId = taskId;
+    this.active = active;
+    this.title = '';
+    this.briefUploaded = false;
+  }
+
+  async start(criteria = {}, input = {}) {
+    if (!this.active) return;
+    this.title = buildTitle(criteria, input, this.taskId);
+    const artifacts = {};
+    try {
+      const brief = await this.uploadBrief(criteria, input);
+      Object.assign(artifacts, brief);
+    } catch (_) { /* 静默 */ }
+    await this.report({
+      status: 'running',
+      progress: 5,
+      title: this.title,
+      type: 'tihao_sourcing',
+      step: { key: 'start', label: '开始提号', at: now() },
+      data: { artifacts },
+    });
+  }
+
+  async step(key, label, progress) {
+    if (!this.active) return;
+    await this.report({
+      status: 'running',
+      progress,
+      step: { key, label, at: now() },
+    });
+  }
+
+  async complete(report = {}) {
+    if (!this.active) return;
+    const artifacts = {};
+    try {
+      Object.assign(artifacts, await this.uploadResults(report.files || []));
+    } catch (_) { /* 静默 */ }
+    await this.report({
+      status: 'completed',
+      progress: 100,
+      step: { key: 'done', label: '已完成', at: now() },
+      data: {
+        artifacts,
+        summary: pickSummary(report.summary),
+      },
+    });
+  }
+
+  async blocked(status, message, progress) {
+    if (!this.active) return;
+    await this.report({
+      status: status || 'blocked',
+      progress: typeof progress === 'number' ? progress : 50,
+      step: { key: 'blocked', label: message || '已暂停', at: now() },
+      data: { note: message || '' },
+    });
+  }
+
+  async fail(error) {
+    if (!this.active) return;
+    const message = error && error.message ? error.message : String(error || '未知错误');
+    await this.report({
+      status: 'failed',
+      step: { key: 'failed', label: '执行失败', at: now() },
+      data: { error: message.slice(0, 500) },
+    });
+  }
+
+  /* --------------------------- 内部 --------------------------- */
+
+  async report(payload) {
+    try {
+      await callFunction({
+        id: PROGRESS_FN_ID,
+        action: 'report',
+        sessionToken: this.sessionToken,
+        taskId: this.taskId,
+        ...payload,
+      });
+    } catch (_) { /* 静默:上报失败不阻断 */ }
+  }
+
+  async uploadBrief(criteria = {}, input = {}) {
+    const source = criteria.source || input.brief || input.briefPath || '';
+    const rawText = String(criteria.rawText || '').trim();
+
+    // 1) 真实文件 brief:原样上传
+    if (source && source !== 'inline' && source !== 'input') {
+      const filePath = path.resolve(source);
+      if (fs.existsSync(filePath) && fs.statSync(filePath).isFile()) {
+        const buffer = fs.readFileSync(filePath);
+        const name = path.basename(filePath);
+        const url = await this.uploadBuffer(buffer, name, mimeOf(name));
+        const out = { briefUrl: url, briefName: name };
+        if (rawText) out.briefPreview = truncate(rawText, BRIEF_PREVIEW_LIMIT);
+        this.briefUploaded = Boolean(url);
+        return out;
+      }
+    }
+
+    // 2) 文本 brief:转 .md 上传 + 预览入库
+    if (rawText) {
+      const name = `brief-${this.taskId}.md`;
+      const url = await this.uploadBuffer(Buffer.from(rawText, 'utf8'), name, 'text/markdown');
+      this.briefUploaded = Boolean(url);
+      return { briefUrl: url, briefName: name, briefPreview: truncate(rawText, BRIEF_PREVIEW_LIMIT) };
+    }
+    return {};
+  }
+
+  async uploadResults(files = []) {
+    const out = {};
+    const csv = files.find((f) => String(f).toLowerCase().endsWith('.csv'));
+    if (csv && fs.existsSync(csv)) {
+      const name = path.basename(csv);
+      const url = await this.uploadBuffer(fs.readFileSync(csv), name, 'text/csv');
+      if (url) { out.resultCsvUrl = url; out.resultCsvName = name; }
+    }
+    const md = files.find((f) => String(f).toLowerCase().endsWith('.md'));
+    if (md && fs.existsSync(md)) {
+      const name = path.basename(md);
+      const url = await this.uploadBuffer(fs.readFileSync(md), name, 'text/markdown');
+      if (url) { out.reportUrl = url; out.reportName = name; }
+    }
+    return out;
+  }
+
+  async uploadBuffer(buffer, filename, mimeType) {
+    if (!buffer || !buffer.length) return '';
+    const tokenResp = await callFunction({
+      id: UPLOAD_FN_ID,
+      action: 'createUploadToken',
+      sessionToken: this.sessionToken,
+      filename,
+      mimeType,
+      kind: 'export',
+      size: buffer.length,
+      bizId: this.taskId,
+    });
+    const data = (tokenResp && tokenResp.data) || {};
+    if (!data.uploadUrl || !data.token || !data.key) return '';
+
+    const form = new FormData();
+    form.append('key', data.key);
+    form.append('token', data.token);
+    form.append('file', new Blob([buffer], { type: mimeType }), filename);
+
+    const controller = new AbortController();
+    const timer = setTimeout(() => controller.abort(), CALL_TIMEOUT_MS);
+    try {
+      const resp = await fetch(data.uploadUrl, { method: 'POST', body: form, signal: controller.signal });
+      if (!resp.ok) return '';
+      return data.url || '';
+    } finally {
+      clearTimeout(timer);
+    }
+  }
+}
+
+async function callFunction(body) {
+  let lastErr;
+  for (let attempt = 0; attempt < CALL_RETRIES; attempt += 1) {
+    const controller = new AbortController();
+    const timer = setTimeout(() => controller.abort(), CALL_TIMEOUT_MS);
+    try {
+      const resp = await fetch(FUNCTIONS_URL, {
+        method: 'POST',
+        headers: {
+          'Content-Type': 'application/json',
+          'Accept': 'application/json',
+          'X-Parse-Application-Id': PARSE_APP_ID,
+          'X-Parse-Session-Token': body.sessionToken || '',
+        },
+        body: JSON.stringify({ ...body, _ApplicationId: PARSE_APP_ID }),
+        signal: controller.signal,
+      });
+      const text = await resp.text();
+      const json = safeJson(text);
+      // FMode 偶发空响应 / fetch failed(云函数内部解析登录态抖动)→ 重试
+      const fetchFailed = json && typeof json.message === 'string' && /fetch failed/i.test(json.message);
+      const retryable = !resp.ok || !json || fetchFailed || Number(json.code) >= 500;
+      if (retryable) {
+        lastErr = new Error((json && (json.message || json.error)) || `empty response (http ${resp.status})`);
+      } else {
+        return json;
+      }
+    } catch (err) {
+      lastErr = err;
+    } finally {
+      clearTimeout(timer);
+    }
+    await sleep(800 * (attempt + 1));
+  }
+  throw lastErr || new Error('callFunction failed');
+}
+
+function buildTitle(criteria = {}, input = {}, taskId) {
+  const brand = String(criteria.brand || input.brand || '').trim();
+  const keywords = Array.isArray(criteria.keywords) ? criteria.keywords.filter(Boolean) : [];
+  const kw = keywords.slice(0, 3).join('、');
+  if (brand && kw) return `${brand} · ${kw}`;
+  if (brand) return brand;
+  if (kw) return kw;
+  return `提号任务 ${taskId}`;
+}
+
+function pickSummary(summary = {}) {
+  if (!summary || typeof summary !== 'object') return {};
+  const keys = ['brand', 'collectionMode', 'total', 'strong', 'backup', 'review', 'clientReadyCount', 'targetCount'];
+  const out = {};
+  for (const key of keys) if (summary[key] !== undefined) out[key] = summary[key];
+  return out;
+}
+
+function mimeOf(name) {
+  const ext = String(name || '').toLowerCase().split('.').pop();
+  return {
+    md: 'text/markdown', txt: 'text/plain', csv: 'text/csv', json: 'application/json',
+    xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
+    xls: 'application/vnd.ms-excel',
+    docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
+    doc: 'application/msword', pdf: 'application/pdf',
+  }[ext] || 'application/octet-stream';
+}
+
+function truncate(text, limit) {
+  const value = String(text || '');
+  return value.length > limit ? `${value.slice(0, limit)}\n…(已截断)` : value;
+}
+
+function safeJson(text) {
+  try { return JSON.parse(text); } catch { return null; }
+}
+
+function now() {
+  return new Date().toISOString();
+}
+
+function sleep(ms) {
+  return new Promise((resolve) => setTimeout(resolve, ms));
+}
+
+module.exports = { createReporter, ProgressReporter };

+ 21 - 1
claude-code-tihao-sourcing/mcp/src/features/tihao-sourcing/sourcing-workflow.js

@@ -12,13 +12,26 @@ const { enrichCriteriaWithExperience } = require('./experience-rules');
 const { enrichHomepageEvidence } = require('./homepage-evidence-provider');
 const { rankCreators } = require('./ranker');
 const { buildSourcingReport } = require('./report');
+const { createReporter } = require('./progress-reporter');
 
 async function runTihaoSourcing(input = {}) {
   const collectionMode = resolveCollectionMode(input);
   const effectiveInput = collectionMode === 'live' ? withLiveSmallRunDefaults(input) : input;
   const outputDir = path.resolve(input.output || input.outputDir || `outputs/tihao-sourcing-${Date.now()}`);
+  const reporter = createReporter(input, { outputDir });
+  try {
+    return await runTihaoSourcingInner(effectiveInput, collectionMode, outputDir, reporter);
+  } catch (error) {
+    await reporter.fail(error);
+    throw error;
+  }
+}
+
+async function runTihaoSourcingInner(input, collectionMode, outputDir, reporter) {
+  const effectiveInput = input;
   let criteria = parseBrief(input);
   criteria = applyMemory(criteria, effectiveInput.memory || effectiveInput.memoryPath);
+  await reporter.start(criteria, input);
   const enrichment = await enrichReferenceBaselines(criteria, effectiveInput);
   criteria = attachReferenceBaselines(criteria, {
     ...effectiveInput,
@@ -40,13 +53,17 @@ async function runTihaoSourcing(input = {}) {
   let recallRecords = [];
   if (collectionMode === 'live') {
     const liveResult = await searchLiveCreators(criteria, effectiveInput);
-    if (liveResult.status !== 'ok') return liveResult;
+    if (liveResult.status !== 'ok') {
+      await reporter.blocked(liveResult.status || 'live_error', '真实检索未通过', 20);
+      return liveResult;
+    }
     creators = liveResult.creators.length ? liveResult.creators : [];
     recallRecords = liveResult.recallRecords || [];
     warnings = [...warnings, ...(liveResult.warnings || [])];
     if (creators.length === 0) {
       warnings.push('live 模式没有返回候选,建议放宽关键词、检查 company,或先用 sample 模式校准 brief。');
       if (!effectiveInput.allowSampleFallback) {
+        await reporter.blocked('no_live_samples', 'live 检索无候选', 30);
         return okResult({
           status: 'no_live_samples',
           assistantMessage: [
@@ -80,6 +97,7 @@ async function runTihaoSourcing(input = {}) {
     }
   }
 
+  await reporter.step('search', '候选检索完成', 45);
   const homepageEvidence = await enrichHomepageEvidence(criteria, creators, effectiveInput);
   creators = homepageEvidence.creators || creators;
   warnings = [...warnings, ...(homepageEvidence.warnings || [])];
@@ -108,6 +126,7 @@ async function runTihaoSourcing(input = {}) {
   });
   criteria = enrichCriteriaWithExperience(criteria);
 
+  await reporter.step('rank', '排序与证据完成', 75);
   const candidates = attachEvidenceCards(preliminaryCandidates, criteria.evidenceCards);
   const report = buildSourcingReport({
     criteria,
@@ -117,6 +136,7 @@ async function runTihaoSourcing(input = {}) {
     warnings
   });
 
+  await reporter.complete(report);
   return okResult({
     assistantMessage: report.assistantMessage,
     summary: report.summary,

+ 1 - 0
src/app/app.html

@@ -62,6 +62,7 @@
       <app-rules-page *ngSwitchCase="'rules'"></app-rules-page>
       <app-learning-page *ngSwitchCase="'learning'"></app-learning-page>
       <app-templates-page *ngSwitchCase="'templates'"></app-templates-page>
+      <app-progress-board-page *ngSwitchCase="'progressBoard'"></app-progress-board-page>
     </ng-container>
   </main>
 </div>

+ 3 - 1
src/app/app.ts

@@ -10,6 +10,7 @@ import { TasksPageComponent } from './pages/tasks-page.component';
 import { TemplatesPageComponent } from './pages/templates-page.component';
 import { WorkspacePageComponent } from './pages/workspace-page.component';
 import { WorkspaceFlowComponent } from '../modules/workspace';
+import { ProgressBoardPageComponent } from './pages/progress-board-page.component';
 
 @Component({
   selector: 'app-root',
@@ -23,7 +24,8 @@ import { WorkspaceFlowComponent } from '../modules/workspace';
     RulesPageComponent,
     LearningPageComponent,
     TemplatesPageComponent,
-    WorkspaceFlowComponent
+    WorkspaceFlowComponent,
+    ProgressBoardPageComponent
   ],
   templateUrl: './app.html',
   styleUrl: './app.scss',

+ 3 - 2
src/app/demo-state.service.ts

@@ -524,10 +524,11 @@ export class DemoStateService {
     { key: 'resources', label: '资源库', note: '本地媒体表管理' },
     { key: 'rules', label: '规则库', note: '经验与提炼规则' },
     { key: 'learning', label: '反馈学习', note: '复盘与自我提升' },
-    { key: 'templates', label: '模板中心', note: 'Brief 与推荐表' }
+    { key: 'templates', label: '模板中心', note: 'Brief 与推荐表' },
+    { key: 'progressBoard', label: '进度看板', note: '人×任务×进度实时' }
   ];
 
-  simpleNavKeys: ViewKey[] = ['workspaceFlow', 'workspace', 'creatorSearch', 'delivery', 'tasks'];
+  simpleNavKeys: ViewKey[] = ['workspaceFlow', 'workspace', 'creatorSearch', 'delivery', 'tasks', 'progressBoard'];
 
   businessShortcuts = [
     {

+ 2 - 1
src/app/models.ts

@@ -7,7 +7,8 @@ export type ViewKey =
   | 'resources'
   | 'rules'
   | 'learning'
-  | 'templates';
+  | 'templates'
+  | 'progressBoard';
 export type StepStatus = 'done' | 'active' | 'todo';
 export type ActivityStatus = '完成' | '进行中' | '待执行';
 export type CandidateStatus = '强推荐' | '备选' | '需复核' | '已剔除';

+ 117 - 0
src/app/pages/progress-board-page.component.html

@@ -0,0 +1,117 @@
+<section class="tihao-board">
+  <!-- 未登录:公司登录 -->
+  <div class="board-login" *ngIf="!loggedIn">
+    <div class="login-card">
+      <h2>题号进度看板登录</h2>
+      <p class="login-hint">使用公司 Parse 账号登录,登录后可查看所有成员的提号任务进度。</p>
+      <form (ngSubmit)="login()">
+        <label>
+          <span>账号</span>
+          <input type="text" name="username" [(ngModel)]="username" placeholder="公司账号 / 用户名" autocomplete="username" />
+        </label>
+        <label>
+          <span>密码</span>
+          <input type="password" name="password" [(ngModel)]="password" placeholder="密码" autocomplete="current-password" />
+        </label>
+        <p class="login-error" *ngIf="loginError">{{ loginError }}</p>
+        <button type="submit" class="primary-button" [disabled]="loggingIn">
+          {{ loggingIn ? '登录中…' : '登录' }}
+        </button>
+      </form>
+      <p class="login-tip">
+        提示:登录后浏览器访问
+        <code>https://server.fmode.cn/parse/users/me</code>
+        可查看自己的 sessionToken(供技能包上报使用)。
+      </p>
+    </div>
+  </div>
+
+  <!-- 已登录:看板 -->
+  <div class="board-main" *ngIf="loggedIn">
+    <header class="board-head">
+      <div>
+        <h2>人 × 任务 × 进度</h2>
+        <p class="board-sub" *ngIf="board?.summary as s">
+          {{ s.memberCount }} 位成员 · {{ s.taskCount }} 个任务
+          <span *ngIf="s.byStatus?.['running']">· 进行中 {{ s.byStatus['running'] }}</span>
+          <span *ngIf="s.byStatus?.['completed']">· 已完成 {{ s.byStatus['completed'] }}</span>
+          <span *ngIf="s.byStatus?.['failed']">· 失败 {{ s.byStatus['failed'] }}</span>
+        </p>
+      </div>
+      <div class="board-actions">
+        <span class="board-meta" *ngIf="lastUpdated">每 5 秒刷新 · 最近 {{ lastUpdated }}</span>
+        <span class="board-meta" *ngIf="currentUser">当前:{{ currentUser.name }}</span>
+        <button type="button" class="ghost-button" (click)="refresh()" [disabled]="loading">手动刷新</button>
+        <button type="button" class="ghost-button" (click)="logout()">退出登录</button>
+      </div>
+    </header>
+
+    <p class="board-error" *ngIf="error">{{ error }}</p>
+
+    <p class="board-empty" *ngIf="board && board.members.length === 0">
+      暂无提号任务记录。技能包跑 live 提号并带上 sessionToken 后,这里会实时出现「人 × 任务 × 进度」。
+    </p>
+
+    <div class="member-list" *ngIf="board">
+      <article class="member-card" *ngFor="let member of board.members; trackBy: trackMember">
+        <div class="member-head">
+          <div class="member-avatar">{{ (member.memberName || '?').slice(0, 1) }}</div>
+          <div>
+            <h3>{{ member.memberName || '未命名成员' }}</h3>
+            <small>{{ member.companyName || (member.companyId ? '公司 ' + member.companyId : '个人') }} · {{ member.tasks.length }} 个任务</small>
+          </div>
+        </div>
+
+        <div class="task-list">
+          <div class="task-row" *ngFor="let task of member.tasks; trackBy: trackTask">
+            <div class="task-top">
+              <div class="task-title">
+                <span class="status-pill" [ngClass]="statusClass(task.status)">{{ statusLabel(task.status) }}</span>
+                <strong>{{ task.title || task.taskId }}</strong>
+              </div>
+              <button type="button" class="link-button" (click)="toggle(taskKey(member, task))">
+                {{ isExpanded(taskKey(member, task)) ? '收起' : '详情' }}
+              </button>
+            </div>
+
+            <div class="progress-line">
+              <div class="progress-track">
+                <div class="progress-fill" [ngClass]="statusClass(task.status)" [style.width.%]="task.progress || 0"></div>
+              </div>
+              <span class="progress-num">{{ task.progress || 0 }}%</span>
+            </div>
+
+            <div class="task-meta">
+              <span *ngIf="task.step">{{ task.step }}</span>
+              <span *ngIf="task.updatedAt">更新 {{ fmt(task.updatedAt) }}</span>
+            </div>
+
+            <!-- 展开:brief / 结果 / 步骤 -->
+            <div class="task-detail" *ngIf="isExpanded(taskKey(member, task))">
+              <div class="artifacts" *ngIf="task.data?.artifacts as art">
+                <div class="artifact-links">
+                  <a *ngIf="art.briefUrl" [href]="art.briefUrl" target="_blank" rel="noopener">原始 Brief{{ art.briefName ? '(' + art.briefName + ')' : '' }}</a>
+                  <a *ngIf="art.resultCsvUrl" [href]="art.resultCsvUrl" target="_blank" rel="noopener">结果 CSV{{ art.resultCsvName ? '(' + art.resultCsvName + ')' : '' }}</a>
+                  <a *ngIf="art.reportUrl" [href]="art.reportUrl" target="_blank" rel="noopener">分析报告{{ art.reportName ? '(' + art.reportName + ')' : '' }}</a>
+                </div>
+                <div class="brief-preview" *ngIf="art.briefPreview">
+                  <span class="preview-label">Brief 预览</span>
+                  <pre>{{ art.briefPreview }}</pre>
+                </div>
+              </div>
+
+              <p class="task-error" *ngIf="task.data?.error">失败原因:{{ task.data?.error }}</p>
+
+              <ol class="step-trace" *ngIf="task.steps?.length">
+                <li *ngFor="let step of task.steps">
+                  <span class="step-label">{{ step.label || step.key }}</span>
+                  <span class="step-at" *ngIf="step.at">{{ fmt(step.at) }}</span>
+                </li>
+              </ol>
+            </div>
+          </div>
+        </div>
+      </article>
+    </div>
+  </div>
+</section>

+ 186 - 0
src/app/pages/progress-board-page.component.scss

@@ -0,0 +1,186 @@
+.tihao-board {
+  display: block;
+  padding: 4px 2px 32px;
+
+  .board-login {
+    display: flex;
+    justify-content: center;
+    padding-top: 40px;
+  }
+
+  .login-card {
+    width: 100%;
+    max-width: 420px;
+    background: #fff;
+    border: 1px solid #e6e8ee;
+    border-radius: 16px;
+    padding: 28px 26px;
+    box-shadow: 0 12px 40px rgba(20, 30, 60, 0.06);
+
+    h2 { margin: 0 0 6px; font-size: 20px; }
+    .login-hint { margin: 0 0 18px; color: #6b7280; font-size: 13px; line-height: 1.5; }
+
+    form { display: flex; flex-direction: column; gap: 14px; }
+    label { display: flex; flex-direction: column; gap: 6px; font-size: 13px; color: #374151; }
+    input {
+      border: 1px solid #d6dae3;
+      border-radius: 10px;
+      padding: 10px 12px;
+      font-size: 14px;
+      outline: none;
+      transition: border-color 0.15s;
+      &:focus { border-color: #4f6ef7; }
+    }
+    .login-error { margin: 0; color: #d23f57; font-size: 13px; }
+    .login-tip { margin: 16px 0 0; color: #9aa1ad; font-size: 12px; line-height: 1.5; }
+    code { background: #f1f3f8; padding: 1px 5px; border-radius: 5px; font-size: 11px; }
+  }
+
+  .primary-button {
+    border: none;
+    background: #4f6ef7;
+    color: #fff;
+    padding: 11px 14px;
+    border-radius: 10px;
+    font-size: 14px;
+    cursor: pointer;
+    transition: background 0.15s;
+    &:hover:not(:disabled) { background: #3f5be0; }
+    &:disabled { opacity: 0.6; cursor: default; }
+  }
+
+  .ghost-button {
+    border: 1px solid #d6dae3;
+    background: #fff;
+    color: #374151;
+    padding: 7px 12px;
+    border-radius: 8px;
+    font-size: 13px;
+    cursor: pointer;
+    &:hover:not(:disabled) { border-color: #4f6ef7; color: #4f6ef7; }
+    &:disabled { opacity: 0.5; cursor: default; }
+  }
+
+  .link-button {
+    border: none;
+    background: none;
+    color: #4f6ef7;
+    cursor: pointer;
+    font-size: 13px;
+    padding: 0;
+  }
+
+  .board-head {
+    display: flex;
+    justify-content: space-between;
+    align-items: flex-start;
+    gap: 16px;
+    flex-wrap: wrap;
+    margin-bottom: 16px;
+
+    h2 { margin: 0; font-size: 20px; }
+    .board-sub { margin: 4px 0 0; color: #6b7280; font-size: 13px; }
+    .board-sub span { margin-left: 4px; }
+  }
+
+  .board-actions { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
+  .board-meta { color: #9aa1ad; font-size: 12px; }
+  .board-error { color: #d23f57; font-size: 13px; margin: 0 0 12px; }
+  .board-empty { color: #6b7280; font-size: 14px; background: #f7f8fb; border-radius: 12px; padding: 24px; }
+
+  .member-list { display: flex; flex-direction: column; gap: 16px; }
+
+  .member-card {
+    background: #fff;
+    border: 1px solid #e6e8ee;
+    border-radius: 14px;
+    padding: 18px 20px;
+  }
+
+  .member-head {
+    display: flex;
+    align-items: center;
+    gap: 12px;
+    margin-bottom: 14px;
+
+    h3 { margin: 0; font-size: 15px; }
+    small { color: #9aa1ad; font-size: 12px; }
+  }
+
+  .member-avatar {
+    width: 38px;
+    height: 38px;
+    border-radius: 50%;
+    background: #eef1fe;
+    color: #4f6ef7;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    font-weight: 600;
+    font-size: 16px;
+  }
+
+  .task-list { display: flex; flex-direction: column; gap: 12px; }
+
+  .task-row {
+    border: 1px solid #eef0f5;
+    border-radius: 12px;
+    padding: 12px 14px;
+    background: #fcfcfe;
+  }
+
+  .task-top { display: flex; justify-content: space-between; align-items: center; gap: 12px; }
+  .task-title { display: flex; align-items: center; gap: 10px; min-width: 0; }
+  .task-title strong { font-size: 14px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+
+  .status-pill {
+    font-size: 11px;
+    padding: 2px 9px;
+    border-radius: 999px;
+    font-weight: 600;
+    white-space: nowrap;
+    &.is-running { background: #e8f0ff; color: #2f6bff; }
+    &.is-completed { background: #e6f7ee; color: #1c9c5b; }
+    &.is-failed { background: #fdeaed; color: #d23f57; }
+    &.is-blocked { background: #fff3e0; color: #c77700; }
+  }
+
+  .progress-line { display: flex; align-items: center; gap: 10px; margin: 10px 0 6px; }
+  .progress-track { flex: 1; height: 7px; background: #eef0f5; border-radius: 999px; overflow: hidden; }
+  .progress-fill {
+    height: 100%;
+    border-radius: 999px;
+    transition: width 0.4s ease;
+    &.is-running { background: #2f6bff; }
+    &.is-completed { background: #1c9c5b; }
+    &.is-failed { background: #d23f57; }
+    &.is-blocked { background: #f0a500; }
+  }
+  .progress-num { font-size: 12px; color: #6b7280; width: 38px; text-align: right; }
+
+  .task-meta { display: flex; gap: 14px; color: #9aa1ad; font-size: 12px; }
+
+  .task-detail { margin-top: 12px; border-top: 1px dashed #e6e8ee; padding-top: 12px; }
+  .artifact-links { display: flex; flex-wrap: wrap; gap: 14px; margin-bottom: 10px; }
+  .artifact-links a { color: #4f6ef7; font-size: 13px; text-decoration: none; }
+  .artifact-links a:hover { text-decoration: underline; }
+
+  .brief-preview { background: #f7f8fb; border-radius: 10px; padding: 10px 12px; }
+  .preview-label { font-size: 12px; color: #9aa1ad; }
+  .brief-preview pre {
+    margin: 6px 0 0;
+    white-space: pre-wrap;
+    word-break: break-word;
+    font-size: 12px;
+    color: #374151;
+    max-height: 220px;
+    overflow: auto;
+  }
+
+  .task-error { color: #d23f57; font-size: 13px; margin: 8px 0 0; }
+
+  .step-trace { margin: 10px 0 0; padding-left: 18px; }
+  .step-trace li { font-size: 12px; color: #6b7280; display: flex; gap: 10px; justify-content: space-between; }
+  .step-label { color: #374151; }
+  .step-at { color: #9aa1ad; }
+}

+ 158 - 0
src/app/pages/progress-board-page.component.ts

@@ -0,0 +1,158 @@
+import { CommonModule } from '@angular/common';
+import { Component, OnDestroy, OnInit } from '@angular/core';
+import { FormsModule } from '@angular/forms';
+import {
+  BoardData,
+  BoardMember,
+  BoardTask,
+  SessionUser,
+  TihaoProgressService,
+} from '../services/tihao-progress.service';
+
+const POLL_INTERVAL_MS = 5000;
+
+@Component({
+  selector: 'app-progress-board-page',
+  standalone: true,
+  imports: [CommonModule, FormsModule],
+  templateUrl: './progress-board-page.component.html',
+  styleUrl: './progress-board-page.component.scss',
+})
+export class ProgressBoardPageComponent implements OnInit, OnDestroy {
+  username = '';
+  password = '';
+  loginError = '';
+  loggingIn = false;
+
+  loggedIn = false;
+  currentUser: SessionUser | null = null;
+
+  board: BoardData | null = null;
+  loading = false;
+  error = '';
+  lastUpdated = '';
+  expanded = new Set<string>();
+
+  private timer: ReturnType<typeof setInterval> | null = null;
+
+  constructor(private readonly api: TihaoProgressService) {}
+
+  ngOnInit(): void {
+    this.loggedIn = this.api.isLoggedIn;
+    this.currentUser = this.api.currentUser;
+    if (this.loggedIn) {
+      this.startPolling();
+    }
+  }
+
+  ngOnDestroy(): void {
+    this.stopPolling();
+  }
+
+  async login(): Promise<void> {
+    if (!this.username || !this.password) {
+      this.loginError = '请输入公司账号和密码';
+      return;
+    }
+    this.loggingIn = true;
+    this.loginError = '';
+    try {
+      this.currentUser = await this.api.login(this.username, this.password);
+      this.loggedIn = true;
+      this.password = '';
+      this.startPolling();
+    } catch (err) {
+      this.loginError = (err as Error).message || '登录失败';
+    } finally {
+      this.loggingIn = false;
+    }
+  }
+
+  logout(): void {
+    this.stopPolling();
+    this.api.logout();
+    this.loggedIn = false;
+    this.currentUser = null;
+    this.board = null;
+    this.error = '';
+  }
+
+  toggle(taskKey: string): void {
+    if (this.expanded.has(taskKey)) {
+      this.expanded.delete(taskKey);
+    } else {
+      this.expanded.add(taskKey);
+    }
+  }
+
+  isExpanded(taskKey: string): boolean {
+    return this.expanded.has(taskKey);
+  }
+
+  taskKey(member: BoardMember, task: BoardTask): string {
+    return `${member.userId}::${task.taskId}`;
+  }
+
+  trackMember = (_: number, member: BoardMember): string => member.userId;
+  trackTask = (_: number, task: BoardTask): string => task.taskId;
+
+  statusLabel(status: string): string {
+    return {
+      running: '进行中',
+      completed: '已完成',
+      failed: '失败',
+      blocked: '已暂停',
+      no_live_samples: '无候选',
+      pending: '待开始',
+    }[status] || status;
+  }
+
+  statusClass(status: string): string {
+    if (status === 'completed') return 'is-completed';
+    if (status === 'failed') return 'is-failed';
+    if (status === 'running') return 'is-running';
+    return 'is-blocked';
+  }
+
+  fmt(value?: string): string {
+    if (!value) return '';
+    const date = new Date(value);
+    if (Number.isNaN(date.getTime())) return value;
+    return date.toLocaleString('zh-CN', { hour12: false });
+  }
+
+  async refresh(): Promise<void> {
+    if (!this.loggedIn) return;
+    this.loading = true;
+    try {
+      this.board = await this.api.board();
+      this.error = '';
+      this.lastUpdated = new Date().toLocaleTimeString('zh-CN', { hour12: false });
+    } catch (err) {
+      const message = (err as Error).message;
+      if (message === 'UNAUTHORIZED') {
+        this.error = '登录已失效,请重新登录';
+        this.logout();
+      } else if (message === 'NETWORK') {
+        this.error = '网络抖动,正在自动重试…';
+      } else {
+        this.error = message || '加载失败';
+      }
+    } finally {
+      this.loading = false;
+    }
+  }
+
+  private startPolling(): void {
+    this.refresh();
+    this.stopPolling();
+    this.timer = setInterval(() => this.refresh(), POLL_INTERVAL_MS);
+  }
+
+  private stopPolling(): void {
+    if (this.timer) {
+      clearInterval(this.timer);
+      this.timer = null;
+    }
+  }
+}

+ 144 - 0
src/app/services/tihao-progress.service.ts

@@ -0,0 +1,144 @@
+import { Injectable } from '@angular/core';
+
+/** FMode 题号进度看板客户端:公司登录 + 调用云函数 tihao-task-progress。 */
+
+const PARSE_SERVER = 'https://server.fmode.cn';
+const PARSE_APP_ID = 'ncloudmaster';
+const PROGRESS_FN_ID = 'WrdzmO5fqr';
+const TOKEN_KEY = 'tihao_session_token';
+const USER_KEY = 'tihao_session_user';
+
+export interface BoardArtifacts {
+  briefUrl?: string;
+  briefName?: string;
+  briefPreview?: string;
+  resultCsvUrl?: string;
+  resultCsvName?: string;
+  reportUrl?: string;
+  reportName?: string;
+}
+
+export interface BoardStep {
+  key?: string;
+  label?: string;
+  at?: string;
+}
+
+export interface BoardTaskData {
+  artifacts?: BoardArtifacts;
+  summary?: Record<string, unknown>;
+  note?: string;
+  error?: string;
+}
+
+export interface BoardTask {
+  taskId: string;
+  title: string;
+  type: string;
+  status: string;
+  progress: number;
+  step?: string;
+  steps?: BoardStep[];
+  updatedAt?: string;
+  createdAt?: string;
+  data?: BoardTaskData;
+}
+
+export interface BoardMember {
+  userId: string;
+  memberName: string;
+  companyId?: string;
+  companyName?: string;
+  tasks: BoardTask[];
+}
+
+export interface BoardSummary {
+  memberCount: number;
+  taskCount: number;
+  byStatus: Record<string, number>;
+  generatedAt?: string;
+}
+
+export interface BoardData {
+  members: BoardMember[];
+  summary: BoardSummary;
+}
+
+export interface SessionUser {
+  userId: string;
+  name: string;
+}
+
+@Injectable({ providedIn: 'root' })
+export class TihaoProgressService {
+  get sessionToken(): string {
+    return (typeof localStorage !== 'undefined' && localStorage.getItem(TOKEN_KEY)) || '';
+  }
+
+  get currentUser(): SessionUser | null {
+    if (typeof localStorage === 'undefined') return null;
+    const raw = localStorage.getItem(USER_KEY);
+    if (!raw) return null;
+    try { return JSON.parse(raw) as SessionUser; } catch { return null; }
+  }
+
+  get isLoggedIn(): boolean {
+    return Boolean(this.sessionToken);
+  }
+
+  async login(username: string, password: string): Promise<SessionUser> {
+    const resp = await fetch(`${PARSE_SERVER}/parse/login`, {
+      method: 'POST',
+      headers: {
+        'Content-Type': 'application/json',
+        'X-Parse-Application-Id': PARSE_APP_ID,
+        'X-Parse-Revocable-Session': '1',
+      },
+      body: JSON.stringify({ username: (username || '').trim(), password }),
+    });
+    const data = await resp.json().catch(() => null);
+    if (!resp.ok || !data || !data.sessionToken) {
+      throw new Error((data && (data.error || data.message)) || '登录失败,请检查账号密码');
+    }
+    const user: SessionUser = { userId: data.objectId, name: data.name || data.username || '我' };
+    localStorage.setItem(TOKEN_KEY, data.sessionToken);
+    localStorage.setItem(USER_KEY, JSON.stringify(user));
+    return user;
+  }
+
+  logout(): void {
+    if (typeof localStorage === 'undefined') return;
+    localStorage.removeItem(TOKEN_KEY);
+    localStorage.removeItem(USER_KEY);
+  }
+
+  async board(): Promise<BoardData> {
+    return this.callFunction<BoardData>('board', {});
+  }
+
+  private async callFunction<T>(action: string, params: Record<string, unknown>): Promise<T> {
+    const token = this.sessionToken;
+    const resp = await fetch(`${PARSE_SERVER}/api/functions`, {
+      method: 'POST',
+      headers: {
+        'Content-Type': 'application/json',
+        'X-Parse-Application-Id': PARSE_APP_ID,
+        'X-Parse-Session-Token': token,
+      },
+      body: JSON.stringify({ id: PROGRESS_FN_ID, action, sessionToken: token, _ApplicationId: PARSE_APP_ID, ...params }),
+    });
+    const json = await resp.json().catch(() => null);
+    if (!json) {
+      throw new Error('NETWORK');
+    }
+    const code = Number(json.code || resp.status);
+    if (code === 401) {
+      this.logout();
+      throw new Error('UNAUTHORIZED');
+    }
+    if (json.success === false || code >= 400) {
+      throw new Error(json.error || json.message || '请求失败');
+    }
+    return json.data as T;
+  }
+}