Explorar el Código

feat(workshop): connect public board data and project progress

pengzhikang hace 3 días
padre
commit
d14d02f

+ 3 - 0
.gitignore

@@ -8,6 +8,9 @@ claude_*_run.log
 
 # 临时产物
 dist/
+!lib/vendor/signal-polyfill/v0.2.2/dist/
+lib/vendor/signal-polyfill/v0.2.2/dist/*
+!lib/vendor/signal-polyfill/v0.2.2/dist/index.js
 *.tar.gz
 .tmp/
 tmp/

+ 583 - 0
lib/vendor/signal-polyfill/v0.2.2/dist/index.js

@@ -0,0 +1,583 @@
+var __defProp = Object.defineProperty;
+var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value;
+var __publicField = (obj, key, value) => {
+  __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value);
+  return value;
+};
+var __accessCheck = (obj, member, msg) => {
+  if (!member.has(obj))
+    throw TypeError("Cannot " + msg);
+};
+var __privateIn = (member, obj) => {
+  if (Object(obj) !== obj)
+    throw TypeError('Cannot use the "in" operator on this value');
+  return member.has(obj);
+};
+var __privateAdd = (obj, member, value) => {
+  if (member.has(obj))
+    throw TypeError("Cannot add the same private member more than once");
+  member instanceof WeakSet ? member.add(obj) : member.set(obj, value);
+};
+var __privateMethod = (obj, member, method) => {
+  __accessCheck(obj, member, "access private method");
+  return method;
+};
+/**
+ * @license
+ * Copyright Google LLC All Rights Reserved.
+ *
+ * Use of this source code is governed by an MIT-style license that can be
+ * found in the LICENSE file at https://angular.io/license
+ */
+function defaultEquals(a, b) {
+  return Object.is(a, b);
+}
+/**
+ * @license
+ * Copyright Google LLC All Rights Reserved.
+ *
+ * Use of this source code is governed by an MIT-style license that can be
+ * found in the LICENSE file at https://angular.io/license
+ */
+let activeConsumer = null;
+let inNotificationPhase = false;
+let epoch = 1;
+const SIGNAL = /* @__PURE__ */ Symbol("SIGNAL");
+function setActiveConsumer(consumer) {
+  const prev = activeConsumer;
+  activeConsumer = consumer;
+  return prev;
+}
+function getActiveConsumer() {
+  return activeConsumer;
+}
+function isInNotificationPhase() {
+  return inNotificationPhase;
+}
+const REACTIVE_NODE = {
+  version: 0,
+  lastCleanEpoch: 0,
+  dirty: false,
+  producerNode: void 0,
+  producerLastReadVersion: void 0,
+  producerIndexOfThis: void 0,
+  nextProducerIndex: 0,
+  liveConsumerNode: void 0,
+  liveConsumerIndexOfThis: void 0,
+  consumerAllowSignalWrites: false,
+  consumerIsAlwaysLive: false,
+  producerMustRecompute: () => false,
+  producerRecomputeValue: () => {
+  },
+  consumerMarkedDirty: () => {
+  },
+  consumerOnSignalRead: () => {
+  }
+};
+function producerAccessed(node) {
+  if (inNotificationPhase) {
+    throw new Error(
+      typeof ngDevMode !== "undefined" && ngDevMode ? `Assertion error: signal read during notification phase` : ""
+    );
+  }
+  if (activeConsumer === null) {
+    return;
+  }
+  activeConsumer.consumerOnSignalRead(node);
+  const idx = activeConsumer.nextProducerIndex++;
+  assertConsumerNode(activeConsumer);
+  if (idx < activeConsumer.producerNode.length && activeConsumer.producerNode[idx] !== node) {
+    if (consumerIsLive(activeConsumer)) {
+      const staleProducer = activeConsumer.producerNode[idx];
+      producerRemoveLiveConsumerAtIndex(staleProducer, activeConsumer.producerIndexOfThis[idx]);
+    }
+  }
+  if (activeConsumer.producerNode[idx] !== node) {
+    activeConsumer.producerNode[idx] = node;
+    activeConsumer.producerIndexOfThis[idx] = consumerIsLive(activeConsumer) ? producerAddLiveConsumer(node, activeConsumer, idx) : 0;
+  }
+  activeConsumer.producerLastReadVersion[idx] = node.version;
+}
+function producerIncrementEpoch() {
+  epoch++;
+}
+function producerUpdateValueVersion(node) {
+  if (!node.dirty && node.lastCleanEpoch === epoch) {
+    return;
+  }
+  if (!node.producerMustRecompute(node) && !consumerPollProducersForChange(node)) {
+    node.dirty = false;
+    node.lastCleanEpoch = epoch;
+    return;
+  }
+  node.producerRecomputeValue(node);
+  node.dirty = false;
+  node.lastCleanEpoch = epoch;
+}
+function producerNotifyConsumers(node) {
+  if (node.liveConsumerNode === void 0) {
+    return;
+  }
+  const prev = inNotificationPhase;
+  inNotificationPhase = true;
+  try {
+    for (const consumer of node.liveConsumerNode) {
+      if (!consumer.dirty) {
+        consumerMarkDirty(consumer);
+      }
+    }
+  } finally {
+    inNotificationPhase = prev;
+  }
+}
+function producerUpdatesAllowed() {
+  return (activeConsumer == null ? void 0 : activeConsumer.consumerAllowSignalWrites) !== false;
+}
+function consumerMarkDirty(node) {
+  var _a;
+  node.dirty = true;
+  producerNotifyConsumers(node);
+  (_a = node.consumerMarkedDirty) == null ? void 0 : _a.call(node.wrapper ?? node);
+}
+function consumerBeforeComputation(node) {
+  node && (node.nextProducerIndex = 0);
+  return setActiveConsumer(node);
+}
+function consumerAfterComputation(node, prevConsumer) {
+  setActiveConsumer(prevConsumer);
+  if (!node || node.producerNode === void 0 || node.producerIndexOfThis === void 0 || node.producerLastReadVersion === void 0) {
+    return;
+  }
+  if (consumerIsLive(node)) {
+    for (let i = node.nextProducerIndex; i < node.producerNode.length; i++) {
+      producerRemoveLiveConsumerAtIndex(node.producerNode[i], node.producerIndexOfThis[i]);
+    }
+  }
+  while (node.producerNode.length > node.nextProducerIndex) {
+    node.producerNode.pop();
+    node.producerLastReadVersion.pop();
+    node.producerIndexOfThis.pop();
+  }
+}
+function consumerPollProducersForChange(node) {
+  assertConsumerNode(node);
+  for (let i = 0; i < node.producerNode.length; i++) {
+    const producer = node.producerNode[i];
+    const seenVersion = node.producerLastReadVersion[i];
+    if (seenVersion !== producer.version) {
+      return true;
+    }
+    producerUpdateValueVersion(producer);
+    if (seenVersion !== producer.version) {
+      return true;
+    }
+  }
+  return false;
+}
+function producerAddLiveConsumer(node, consumer, indexOfThis) {
+  var _a;
+  assertProducerNode(node);
+  assertConsumerNode(node);
+  if (node.liveConsumerNode.length === 0) {
+    (_a = node.watched) == null ? void 0 : _a.call(node.wrapper);
+    for (let i = 0; i < node.producerNode.length; i++) {
+      node.producerIndexOfThis[i] = producerAddLiveConsumer(node.producerNode[i], node, i);
+    }
+  }
+  node.liveConsumerIndexOfThis.push(indexOfThis);
+  return node.liveConsumerNode.push(consumer) - 1;
+}
+function producerRemoveLiveConsumerAtIndex(node, idx) {
+  var _a;
+  assertProducerNode(node);
+  assertConsumerNode(node);
+  if (typeof ngDevMode !== "undefined" && ngDevMode && idx >= node.liveConsumerNode.length) {
+    throw new Error(
+      `Assertion error: active consumer index ${idx} is out of bounds of ${node.liveConsumerNode.length} consumers)`
+    );
+  }
+  if (node.liveConsumerNode.length === 1) {
+    (_a = node.unwatched) == null ? void 0 : _a.call(node.wrapper);
+    for (let i = 0; i < node.producerNode.length; i++) {
+      producerRemoveLiveConsumerAtIndex(node.producerNode[i], node.producerIndexOfThis[i]);
+    }
+  }
+  const lastIdx = node.liveConsumerNode.length - 1;
+  node.liveConsumerNode[idx] = node.liveConsumerNode[lastIdx];
+  node.liveConsumerIndexOfThis[idx] = node.liveConsumerIndexOfThis[lastIdx];
+  node.liveConsumerNode.length--;
+  node.liveConsumerIndexOfThis.length--;
+  if (idx < node.liveConsumerNode.length) {
+    const idxProducer = node.liveConsumerIndexOfThis[idx];
+    const consumer = node.liveConsumerNode[idx];
+    assertConsumerNode(consumer);
+    consumer.producerIndexOfThis[idxProducer] = idx;
+  }
+}
+function consumerIsLive(node) {
+  var _a;
+  return node.consumerIsAlwaysLive || (((_a = node == null ? void 0 : node.liveConsumerNode) == null ? void 0 : _a.length) ?? 0) > 0;
+}
+function assertConsumerNode(node) {
+  node.producerNode ?? (node.producerNode = []);
+  node.producerIndexOfThis ?? (node.producerIndexOfThis = []);
+  node.producerLastReadVersion ?? (node.producerLastReadVersion = []);
+}
+function assertProducerNode(node) {
+  node.liveConsumerNode ?? (node.liveConsumerNode = []);
+  node.liveConsumerIndexOfThis ?? (node.liveConsumerIndexOfThis = []);
+}
+/**
+ * @license
+ * Copyright Google LLC All Rights Reserved.
+ *
+ * Use of this source code is governed by an MIT-style license that can be
+ * found in the LICENSE file at https://angular.io/license
+ */
+function computedGet(node) {
+  producerUpdateValueVersion(node);
+  producerAccessed(node);
+  if (node.value === ERRORED) {
+    throw node.error;
+  }
+  return node.value;
+}
+function createComputed(computation) {
+  const node = Object.create(COMPUTED_NODE);
+  node.computation = computation;
+  const computed = () => computedGet(node);
+  computed[SIGNAL] = node;
+  return computed;
+}
+const UNSET = /* @__PURE__ */ Symbol("UNSET");
+const COMPUTING = /* @__PURE__ */ Symbol("COMPUTING");
+const ERRORED = /* @__PURE__ */ Symbol("ERRORED");
+const COMPUTED_NODE = /* @__PURE__ */ (() => {
+  return {
+    ...REACTIVE_NODE,
+    value: UNSET,
+    dirty: true,
+    error: null,
+    equal: defaultEquals,
+    producerMustRecompute(node) {
+      return node.value === UNSET || node.value === COMPUTING;
+    },
+    producerRecomputeValue(node) {
+      if (node.value === COMPUTING) {
+        throw new Error("Detected cycle in computations.");
+      }
+      const oldValue = node.value;
+      node.value = COMPUTING;
+      const prevConsumer = consumerBeforeComputation(node);
+      let newValue;
+      let wasEqual = false;
+      try {
+        newValue = node.computation.call(node.wrapper);
+        const oldOk = oldValue !== UNSET && oldValue !== ERRORED;
+        wasEqual = oldOk && node.equal.call(node.wrapper, oldValue, newValue);
+      } catch (err) {
+        newValue = ERRORED;
+        node.error = err;
+      } finally {
+        consumerAfterComputation(node, prevConsumer);
+      }
+      if (wasEqual) {
+        node.value = oldValue;
+        return;
+      }
+      node.value = newValue;
+      node.version++;
+    }
+  };
+})();
+/**
+ * @license
+ * Copyright Google LLC All Rights Reserved.
+ *
+ * Use of this source code is governed by an MIT-style license that can be
+ * found in the LICENSE file at https://angular.io/license
+ */
+function defaultThrowError() {
+  throw new Error();
+}
+let throwInvalidWriteToSignalErrorFn = defaultThrowError;
+function throwInvalidWriteToSignalError() {
+  throwInvalidWriteToSignalErrorFn();
+}
+/**
+ * @license
+ * Copyright Google LLC All Rights Reserved.
+ *
+ * Use of this source code is governed by an MIT-style license that can be
+ * found in the LICENSE file at https://angular.io/license
+ */
+function createSignal(initialValue) {
+  const node = Object.create(SIGNAL_NODE);
+  node.value = initialValue;
+  const getter = () => {
+    producerAccessed(node);
+    return node.value;
+  };
+  getter[SIGNAL] = node;
+  return getter;
+}
+function signalGetFn() {
+  producerAccessed(this);
+  return this.value;
+}
+function signalSetFn(node, newValue) {
+  if (!producerUpdatesAllowed()) {
+    throwInvalidWriteToSignalError();
+  }
+  if (!node.equal.call(node.wrapper, node.value, newValue)) {
+    node.value = newValue;
+    signalValueChanged(node);
+  }
+}
+const SIGNAL_NODE = /* @__PURE__ */ (() => {
+  return {
+    ...REACTIVE_NODE,
+    equal: defaultEquals,
+    value: void 0
+  };
+})();
+function signalValueChanged(node) {
+  node.version++;
+  producerIncrementEpoch();
+  producerNotifyConsumers(node);
+}
+/**
+ * @license
+ * Copyright 2024 Bloomberg Finance L.P.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+const NODE = Symbol("node");
+var Signal;
+((Signal2) => {
+  var _a, _brand, brand_fn, _b, _brand2, brand_fn2;
+  class State {
+    constructor(initialValue, options = {}) {
+      __privateAdd(this, _brand);
+      __publicField(this, _a);
+      const ref = createSignal(initialValue);
+      const node = ref[SIGNAL];
+      this[NODE] = node;
+      node.wrapper = this;
+      if (options) {
+        const equals = options.equals;
+        if (equals) {
+          node.equal = equals;
+        }
+        node.watched = options[Signal2.subtle.watched];
+        node.unwatched = options[Signal2.subtle.unwatched];
+      }
+    }
+    get() {
+      if (!(0, Signal2.isState)(this))
+        throw new TypeError("Wrong receiver type for Signal.State.prototype.get");
+      return signalGetFn.call(this[NODE]);
+    }
+    set(newValue) {
+      if (!(0, Signal2.isState)(this))
+        throw new TypeError("Wrong receiver type for Signal.State.prototype.set");
+      if (isInNotificationPhase()) {
+        throw new Error("Writes to signals not permitted during Watcher callback");
+      }
+      const ref = this[NODE];
+      signalSetFn(ref, newValue);
+    }
+  }
+  _a = NODE;
+  _brand = new WeakSet();
+  brand_fn = function() {
+  };
+  Signal2.isState = (s) => typeof s === "object" && __privateIn(_brand, s);
+  Signal2.State = State;
+  class Computed {
+    // Create a Signal which evaluates to the value returned by the callback.
+    // Callback is called with this signal as the parameter.
+    constructor(computation, options) {
+      __privateAdd(this, _brand2);
+      __publicField(this, _b);
+      const ref = createComputed(computation);
+      const node = ref[SIGNAL];
+      node.consumerAllowSignalWrites = true;
+      this[NODE] = node;
+      node.wrapper = this;
+      if (options) {
+        const equals = options.equals;
+        if (equals) {
+          node.equal = equals;
+        }
+        node.watched = options[Signal2.subtle.watched];
+        node.unwatched = options[Signal2.subtle.unwatched];
+      }
+    }
+    get() {
+      if (!(0, Signal2.isComputed)(this))
+        throw new TypeError("Wrong receiver type for Signal.Computed.prototype.get");
+      return computedGet(this[NODE]);
+    }
+  }
+  _b = NODE;
+  _brand2 = new WeakSet();
+  brand_fn2 = function() {
+  };
+  Signal2.isComputed = (c) => typeof c === "object" && __privateIn(_brand2, c);
+  Signal2.Computed = Computed;
+  ((subtle2) => {
+    var _a2, _brand3, brand_fn3, _assertSignals, assertSignals_fn;
+    function untrack(cb) {
+      let output;
+      let prevActiveConsumer = null;
+      try {
+        prevActiveConsumer = setActiveConsumer(null);
+        output = cb();
+      } finally {
+        setActiveConsumer(prevActiveConsumer);
+      }
+      return output;
+    }
+    subtle2.untrack = untrack;
+    function introspectSources(sink) {
+      var _a3;
+      if (!(0, Signal2.isComputed)(sink) && !(0, Signal2.isWatcher)(sink)) {
+        throw new TypeError("Called introspectSources without a Computed or Watcher argument");
+      }
+      return ((_a3 = sink[NODE].producerNode) == null ? void 0 : _a3.map((n) => n.wrapper)) ?? [];
+    }
+    subtle2.introspectSources = introspectSources;
+    function introspectSinks(signal) {
+      var _a3;
+      if (!(0, Signal2.isComputed)(signal) && !(0, Signal2.isState)(signal)) {
+        throw new TypeError("Called introspectSinks without a Signal argument");
+      }
+      return ((_a3 = signal[NODE].liveConsumerNode) == null ? void 0 : _a3.map((n) => n.wrapper)) ?? [];
+    }
+    subtle2.introspectSinks = introspectSinks;
+    function hasSinks(signal) {
+      if (!(0, Signal2.isComputed)(signal) && !(0, Signal2.isState)(signal)) {
+        throw new TypeError("Called hasSinks without a Signal argument");
+      }
+      const liveConsumerNode = signal[NODE].liveConsumerNode;
+      if (!liveConsumerNode)
+        return false;
+      return liveConsumerNode.length > 0;
+    }
+    subtle2.hasSinks = hasSinks;
+    function hasSources(signal) {
+      if (!(0, Signal2.isComputed)(signal) && !(0, Signal2.isWatcher)(signal)) {
+        throw new TypeError("Called hasSources without a Computed or Watcher argument");
+      }
+      const producerNode = signal[NODE].producerNode;
+      if (!producerNode)
+        return false;
+      return producerNode.length > 0;
+    }
+    subtle2.hasSources = hasSources;
+    class Watcher {
+      // When a (recursive) source of Watcher is written to, call this callback,
+      // if it hasn't already been called since the last `watch` call.
+      // No signals may be read or written during the notify.
+      constructor(notify) {
+        __privateAdd(this, _brand3);
+        __privateAdd(this, _assertSignals);
+        __publicField(this, _a2);
+        let node = Object.create(REACTIVE_NODE);
+        node.wrapper = this;
+        node.consumerMarkedDirty = notify;
+        node.consumerIsAlwaysLive = true;
+        node.consumerAllowSignalWrites = false;
+        node.producerNode = [];
+        this[NODE] = node;
+      }
+      // Add these signals to the Watcher's set, and set the watcher to run its
+      // notify callback next time any signal in the set (or one of its dependencies) changes.
+      // Can be called with no arguments just to reset the "notified" state, so that
+      // the notify callback will be invoked again.
+      watch(...signals) {
+        if (!(0, Signal2.isWatcher)(this)) {
+          throw new TypeError("Called unwatch without Watcher receiver");
+        }
+        __privateMethod(this, _assertSignals, assertSignals_fn).call(this, signals);
+        const node = this[NODE];
+        node.dirty = false;
+        const prev = setActiveConsumer(node);
+        for (const signal of signals) {
+          producerAccessed(signal[NODE]);
+        }
+        setActiveConsumer(prev);
+      }
+      // Remove these signals from the watched set (e.g., for an effect which is disposed)
+      unwatch(...signals) {
+        if (!(0, Signal2.isWatcher)(this)) {
+          throw new TypeError("Called unwatch without Watcher receiver");
+        }
+        __privateMethod(this, _assertSignals, assertSignals_fn).call(this, signals);
+        const node = this[NODE];
+        assertConsumerNode(node);
+        for (let i = node.producerNode.length - 1; i >= 0; i--) {
+          if (signals.includes(node.producerNode[i].wrapper)) {
+            producerRemoveLiveConsumerAtIndex(node.producerNode[i], node.producerIndexOfThis[i]);
+            const lastIdx = node.producerNode.length - 1;
+            node.producerNode[i] = node.producerNode[lastIdx];
+            node.producerIndexOfThis[i] = node.producerIndexOfThis[lastIdx];
+            node.producerNode.length--;
+            node.producerIndexOfThis.length--;
+            node.nextProducerIndex--;
+            if (i < node.producerNode.length) {
+              const idxConsumer = node.producerIndexOfThis[i];
+              const producer = node.producerNode[i];
+              assertProducerNode(producer);
+              producer.liveConsumerIndexOfThis[idxConsumer] = i;
+            }
+          }
+        }
+      }
+      // Returns the set of computeds in the Watcher's set which are still yet
+      // to be re-evaluated
+      getPending() {
+        if (!(0, Signal2.isWatcher)(this)) {
+          throw new TypeError("Called getPending without Watcher receiver");
+        }
+        const node = this[NODE];
+        return node.producerNode.filter((n) => n.dirty).map((n) => n.wrapper);
+      }
+    }
+    _a2 = NODE;
+    _brand3 = new WeakSet();
+    brand_fn3 = function() {
+    };
+    _assertSignals = new WeakSet();
+    assertSignals_fn = function(signals) {
+      for (const signal of signals) {
+        if (!(0, Signal2.isComputed)(signal) && !(0, Signal2.isState)(signal)) {
+          throw new TypeError("Called watch/unwatch without a Computed or State argument");
+        }
+      }
+    };
+    Signal2.isWatcher = (w) => __privateIn(_brand3, w);
+    subtle2.Watcher = Watcher;
+    function currentComputed() {
+      var _a3;
+      return (_a3 = getActiveConsumer()) == null ? void 0 : _a3.wrapper;
+    }
+    subtle2.currentComputed = currentComputed;
+    subtle2.watched = Symbol("watched");
+    subtle2.unwatched = Symbol("unwatched");
+  })(Signal2.subtle || (Signal2.subtle = {}));
+})(Signal || (Signal = {}));
+export {
+  Signal
+};

+ 1 - 0
src/app.config.js

@@ -15,6 +15,7 @@ const LOCAL_OVERRIDE =
 export const CONFIG = Object.freeze({
   parseBase: String(LOCAL_OVERRIDE || '/parse').replace(/\/+$/, ''),
   appId: 'FKieRbIoGt_main',
+  functionsUrl: 'https://kangshifu.fmode.cn/api/functions',
   classes: Object.freeze({
     project: 'KfProject',
     artifact: 'KfArtifact',

+ 27 - 7
src/core/kfs/build.js

@@ -17,6 +17,21 @@ export function buildModel(raw, { now = Date.now(), stuck = { minMsgs: 5, stuckH
     session: Array.isArray(raw.session) ? raw.session : [],
     opportunity: Array.isArray(raw.opportunity) ? raw.opportunity : [],
   };
+  // New sessions have stable participant keys. Only disambiguate display names when needed.
+  const identities = new Map();
+  for (const s of rows.session) {
+    const key=s.meta?.participantKey;
+    if(key){if(!identities.has(s.studentName))identities.set(s.studentName,new Map());identities.get(s.studentName).set(key,s.groupId||key.slice(-6));}
+  }
+  const displayName=(name,key)=>{
+    const people=identities.get(name);if(!people||people.size<2)return name;
+    const group=people.get(key);
+    const repeated=group&&[...people.values()].filter(g=>g===group).length>1;
+    return `${name}(${group||key?.slice(-8)||'未绑定'}${repeated?' · '+key.slice(-8):''})`;
+  };
+  rows.session=rows.session.map(s=>({...s,studentName:displayName(s.studentName,s.meta?.participantKey)}));
+  for(const k of ['artifact','project'])rows[k]=rows[k].map(r=>({...r,ownerName:displayName(r.ownerName,r.uploadedByKey)}));
+  rows.opportunity=rows.opportunity.map(r=>({...r,uploadedBy:displayName(r.uploadedBy,r.uploadedByKey)}));
 
   /* ---------- 成果 ---------- */
   const citedIds = new Set();
@@ -41,12 +56,15 @@ export function buildModel(raw, { now = Date.now(), stuck = { minMsgs: 5, stuckH
   /* ---------- 项目 ---------- */
   const projects = rows.project.map((r) => {
     const mine = artifacts.filter((a) => a.projectId === r.objectId);
-    const st = stageOf(r.stage);
+    const st = stageOf(r.stageCode || r.stage);
     const own = mine.filter((a) => a.stageObj).sort((x, y) => y.stageObj.no - x.stageObj.no);
     return {
       id: r.objectId,
       title: String(r.title || '(未命名项目)'),
       ownerName: String(r.ownerName || ''),
+      progressState: String(r.progressState || ''),
+      currentConceptId: String(r.currentConceptRef?.objectId || ''),
+      pendingQuestions: r.meta?.pendingQuestions || [],
       ownerKey: String(r.ownerSession || r.ownerName || ''),
       stage: st || (own[0] ? own[0].stageObj : null),
       category: String(r.category || ''),
@@ -129,12 +147,12 @@ export function buildModel(raw, { now = Date.now(), stuck = { minMsgs: 5, stuckH
     return m;
   };
   for (const s of rows.session) {
-    const m = touch(s.studentName);
+    const m = touch(s.studentName, s.meta?.participantKey || '');
     if (!m) continue;
     m.sessionIds.push(s.objectId);
     m.groupId = m.groupId || String(s.groupId || '');
     m.msgCount = Math.max(m.msgCount, Number(s.msgCount) || 0);
-    const st = stageOf(s.stage);
+    const st = stageOf(s.stageCode || s.stage);
     if (st && (!m.stageObj || st.no > m.stageObj.no)) m.stageObj = st;
     // lastActiveAt 缺失时退回会话 createdAt(「一开课就在」,不是「刚刚活跃」)
     if (!s.lastActiveAt) m.stale = true;
@@ -153,6 +171,8 @@ export function buildModel(raw, { now = Date.now(), stuck = { minMsgs: 5, stuckH
     const m = touch(p.ownerName, p.ownerKey);
     if (!m) continue;
     m.projectIds.push(p.id);
+    if(p.progressState==='blocked')m.blocked=true;
+    if(p.stage&&(!m.stageObj||p.stage.no>m.stageObj.no))m.stageObj=p.stage;
     m.lastActiveAt = latest([m.lastActiveAt, p.lastActiveAt]);
   }
   for (const o of opportunities) {
@@ -165,7 +185,7 @@ export function buildModel(raw, { now = Date.now(), stuck = { minMsgs: 5, stuckH
     m.stagesSubmitted = STAGES.filter((s) => m.stagesSubmitted.has(s.code)).map((s) => s.code);
     const idleMs = m.lastActiveAt ? now - toTime(m.lastActiveAt) : null;
     m.idleHours = idleMs == null ? null : Math.floor(idleMs / 3600000);
-    m.stuck = !m.done && m.msgCount >= (stuck.minMsgs || 5) && (idleMs == null || idleMs > stuckMs);
+    m.stuck = Boolean(m.blocked) || (!m.done && m.msgCount >= (stuck.minMsgs || 5) && (idleMs == null || idleMs > stuckMs));
     m.stuckHours = m.stuck ? Math.floor((idleMs || 0) / 3600000) : 0;
     return m;
   }).sort((a, b) => a.name.localeCompare(b.name, 'zh'));
@@ -182,14 +202,14 @@ function normArtifact(r, citedIds, toTimeFn) {
   const ownerKey = String(r.ownerSession || r.uploadedByKey || r.ownerName || '');
   const quotes = Array.isArray(r.sources) ? r.sources.filter((q) => q && (q.text || q.ref)) : [];
   const ev = evidenceOf(r.evidenceLevel, quotes.length > 0);
-  const st = stageOf(r.stage);
+  const st = stageOf(r.stageCode || r.stage);
   const method = String(r.uploadMethod || (['session', 'skill', 'manual'].includes(r.uploadedBy) ? r.uploadedBy : ''));
   return {
     id: r.objectId,
     title: String(r.title || '(未命名成果)'),
     // 演示数据标题形如「… - opportunity_card」,剥掉类型后缀做展示名
     shortTitle: String(r.title || '(未命名成果)').replace(/\s*-\s*[a-z_]+$/, ''),
-    type: String(r.artifactType || ''),
+    type: String(r.artifactType || ({market_trend_report:'category_report',competitor_analysis_report:'competitor_report',voc_report:'voc_report',pain_point_report:'pain_list',product_concept_report:'concept'}[r.reportType]) || ''),
     ownerName: String(r.ownerName || ''),
     ownerKey,
     stageObj: st,
@@ -202,7 +222,7 @@ function normArtifact(r, citedIds, toTimeFn) {
     quotes,
     quoteCount: quotes.length,
     evidenceCount: Number(r.evidenceCount) || 0,
-    projectId: String(r.projectId || ''),
+    projectId: String(r.projectRef?.objectId || r.projectId || ''),
     rawText: String(r.rawText || ''),
     report: r.report && typeof r.report === 'object' && !Array.isArray(r.report) ? r.report : null,
     /** 痛点严重度(整理人标注,1–5);没标就是 null,展示层标「待标注」,不猜 */

+ 2 - 0
src/core/library/reports.js

@@ -23,6 +23,8 @@ const WEEK = 7 * DAY;
 
 /** artifactType(库里存的)→ 类型页 key。多映射时取第一个。 */
 export const TYPE_FOR_ARTIFACT = Object.freeze({
+  category_report: 'category',
+  assessment_report: 'concept',
   competitor_report: 'competitor',
   voc_report: 'voc',
   pain_list: 'pain',

+ 1 - 1
src/core/opportunity/opportunity.js

@@ -56,7 +56,7 @@ export function normDecision(d) {
  */
 export function normalizeOpportunity(raw, ctx = {}) {
   const { artifactById = {}, projectById = {} } = ctx;
-  const st = stageOf(raw.stage);
+  const st = stageOf(raw.stageCode || raw.stage);
   const status = OPP_STATUS_KEYS.includes(raw.status) ? raw.status : 'candidate';
   const ev = oppEvidenceOf(raw.evidenceLevel);
   const stats = scoreStats(raw.scores, toTime);

+ 2 - 0
src/core/taxonomy/taxonomy.js

@@ -71,6 +71,8 @@ export const OPP_EVIDENCE = Object.freeze([
 ]);
 
 export const ARTIFACT_TYPES = Object.freeze({
+  category_report: '品类报告',
+  assessment_report: '机会评估',
   opportunity_card: '机会卡',
   pain_list: '痛点清单',
   competitor_report: '竞品报告',

+ 21 - 0
src/pages/view-members.js

@@ -108,6 +108,27 @@ export class ViewMembers extends LitElement {
             </li>`)}
           </ul>
         </kf-glass-card>`)}
+      ${db.projects.some((p) => p.progressState) ? html`
+        <kf-glass-card surface="2" class="gcard">
+          <div class="gh"><h3 class="tb-cap">项目进展</h3><span class="n">课堂任务进度 · 不代表企业审批</span></div>
+          <ul class="list">
+            <li class="head"><span></span><span>项目</span><span>当前阶段</span><span>负责人</span><span>成果</span><span>方案</span><span>最后活动</span><span>状态</span></li>
+            ${db.projects.filter((p) => p.progressState).map((p, i) => {
+              const report = p.artifactIds.map((id) => db.artifactById[id]).sort((a, b) => b.createdAt.localeCompare(a.createdAt))[0];
+              const state = p.progressState === 'blocked' ? '卡住' : p.progressState === 'completed' ? '课堂完成' : '进行中';
+              return html`<li data-project=${p.id}>
+                <span class="av">${i + 1}</span>
+                <span class="nm">${report ? html`<a href=${href(`/report/${report.id}`)} data-link title=${p.title}>${p.title}</a>` : p.title}</span>
+                <span>${p.stage ? t(`${p.stage.code} ${p.stage.name} · ${state}`) : `未开始 · ${state}`}</span>
+                <span>${p.ownerName}</span>
+                <span class="num">${p.artifactIds.length}<span class="u">份</span></span>
+                <span title=${p.pendingQuestions.join(';')}>${p.currentConceptId ? '已保存' : '待整理'}</span>
+                <span title=${fmtDate(p.lastActiveAt, true)}>${fmtAgo(p.lastActiveAt)}</span>
+                <span class=${p.progressState === 'blocked' ? 'stuck' : p.progressState === 'completed' ? 'ok' : ''}>${state}</span>
+              </li>`;
+            })}
+          </ul>
+        </kf-glass-card>` : ''}
     `;
   }
 }

+ 4 - 13
src/services/board-service.js

@@ -12,7 +12,8 @@
  */
 import { signal } from '@lit-labs/signals';
 import { CONFIG } from '../app.config.js';
-import { createHttp, fetchAll } from '../utils/http.js';
+import { createHttp } from '../utils/http.js';
+import { loadPublicBoard } from './workshop-data.js';
 import { createStorage } from '../utils/storage.js';
 import {
   buildModel, forMe as coreForMe, STATUS_DECISION, statusPatch, scorePatch, scoreStats, toTime, oppStatusOf,
@@ -41,22 +42,12 @@ export class BoardService {
     if (!isRefresh) this.status.set('loading');
     this.#inflight = (async () => {
       try {
-        const names = Object.keys(CONFIG.classes);
-        const results = await Promise.all(names.map((n) => fetchAll(this.#http, CONFIG.classes[n], { limit: CONFIG.pageLimit })));
+        const raw = await loadPublicBoard({ url: CONFIG.functionsUrl, appId: CONFIG.appId });
         // 验收专用:?kfsnap=1 时把原始行快照存在本页 sessionStorage,供验收脚本做「刷新不丢」的离线重载(生产不带这个参数就不发生)
         if (typeof window !== 'undefined' && typeof sessionStorage !== 'undefined' && typeof location !== 'undefined' && new URLSearchParams(location.search).get('kfsnap') === '1') {
-          const snap = { loadedAt: new Date().toISOString(), truncated: [] };
-          names.forEach((n, i) => { snap[n] = results[i].rows; });
-          sessionStorage.setItem('__kfraw_snapshot', JSON.stringify(snap));
+          sessionStorage.setItem('__kfraw_snapshot', JSON.stringify(raw));
         }
-        const raw = { loadedAt: new Date().toISOString(), truncated: [] };
-        const byName = {};
-        names.forEach((n, i) => {
-          byName[n] = results[i].rows;
-          if (results[i].truncated) raw.truncated.push(CONFIG.classes[n]);
-        });
         // 原始行里 Parse 的 Date 是 {__type,iso};core.toTime 能直接读,这里保留原始形态便可
-        Object.assign(raw, byName);
         // 中英混排 U+2009 在数据边界统一落地(v11c):视图与组件拿到的就已经是正确文本
         const db = cjkDeep(buildModel(raw, { now: Date.now(), stuck: CONFIG.stuck }));
         this.db.set(db);

+ 16 - 0
src/services/workshop-data.js

@@ -0,0 +1,16 @@
+/** Public reads require no account. Service credentials are never sent by this module. */
+export async function loadPublicBoard({url='/api/functions',appId,fetchImpl=(...args)=>fetch(...args)}) {
+  const names=['project','artifact','insight','source','session','opportunity'];
+  const raw=Object.fromEntries(names.map(n=>[n,[]]));raw.truncated=[];
+  let cursor=null;
+  for(let page=0;page<500;page++){
+    const response=await fetchImpl(url,{method:'POST',headers:{'Content-Type':'application/json','X-Parse-Application-Id':appId},
+      body:JSON.stringify({path:'/ksfGetPublicBoard',params:{limit:500,...(cursor?{cursor}:{})}}),cache:'no-store',signal:AbortSignal.timeout(30000)});
+    const result=(await response.json())?.result;
+    if(!response.ok||result?.ok!==true||!result.data)throw new Error(result?.errors?.[0]?.message||'看板数据读取失败');
+    for(const name of names){if(!Array.isArray(result.data[name]))throw new Error('看板返回字段不完整');raw[name].push(...result.data[name]);}
+    raw.loadedAt=result.data.asOf;cursor=result.data.nextCursor;
+    if(!cursor)return raw;
+  }
+  throw new Error('看板分页超过上限,请缩小数据范围');
+}