Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 1 addition & 4 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -36,10 +36,7 @@ <h1>影伴</h1>
<div class="body">
<nav class="nav" id="nav">
<button class="navbtn active" data-mod="home" aria-current="page"><span class="ic" data-icon="home"></span>首页</button>
<button class="navbtn" data-mod="chinese"><span class="ic" data-icon="book"></span>语文</button>
<button class="navbtn" data-mod="math"><span class="ic" data-icon="calculator"></span>数学</button>
<button class="navbtn" data-mod="english"><span class="ic" data-icon="languages"></span>英语</button>
<button class="navbtn" data-mod="book"><span class="ic" data-icon="library"></span>绘本</button>
<button class="navbtn" data-mod="learning"><span class="ic" data-icon="graduation"></span>学习</button>
<button class="navbtn" data-mod="points"><span class="ic" data-icon="star"></span>积分</button>
<button class="navbtn" data-mod="grow"><span class="ic" data-icon="sprout"></span>成长</button>
<button class="navbtn" data-mod="guide"><span class="ic" data-icon="compass"></span>指南</button>
Expand Down
11 changes: 11 additions & 0 deletions src/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,17 @@
padding:4px 10px;border-radius:999px;font-weight:700;
}

/* 学习包启停开关 */
.switch-row{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:12px 2px;border-top:1px solid var(--line);}
.switch-row:first-of-type{border-top:none;}
.switch-label{display:flex;flex-direction:column;gap:2px;font-size:14px;font-weight:700;}
.switch-label .desc{font-size:11.5px;color:var(--ink-soft);font-weight:400;margin:0;}
.switch-row input[type="checkbox"]{width:44px;height:26px;appearance:none;flex:0 0 auto;border-radius:999px;background:var(--line);border:2px solid var(--line);position:relative;cursor:pointer;transition:background-color .18s ease,border-color .18s ease;margin:0;}
.switch-row input[type="checkbox"]::after{content:"";position:absolute;top:2px;left:2px;width:18px;height:18px;border-radius:50%;background:#fff;box-shadow:var(--shadow);transition:transform .18s ease;}
.switch-row input[type="checkbox"]:checked{background:var(--green);border-color:var(--green);}
.switch-row input[type="checkbox"]:checked::after{transform:translateX(18px);}
.switch-row input[type="checkbox"]:focus-visible{outline:3px solid var(--focus);outline-offset:2px;}

/* 识字卡片 */
.hanzi-card{display:flex;flex-direction:column;align-items:center;padding:14px 8px;}
.hanzi-big{font-size:72px;font-weight:800;color:var(--ink);line-height:1;}
Expand Down
298 changes: 228 additions & 70 deletions src/app.js

Large diffs are not rendered by default.

85 changes: 85 additions & 0 deletions src/learning-content-package.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
// 启蒙学习包 v1 —— 轻量代码注册表。
// 这是唯一的内容包/模块定义来源:稳定 ID、名称、图标、既有内容入口与记录类型。
// 孩子级启停配置保存在学习状态 envelope 的 learning.content_config 中,
// 不新增内容包数据库表;本模块不产生积分流水。

export const CONTENT_CONFIG_SCHEMA_VERSION = 1;

export const FOUNDATION_PACKAGE = Object.freeze({
id: "foundation-v1",
version: 1,
name: "启蒙学习包 v1",
suggested_age: "4-5",
goals: ["识字与阅读", "数感启蒙", "英语启蒙", "亲子共读"],
modules: Object.freeze([
{ id: "chinese", name: "语文学习", icon_key: "book", content_entry: "chinese", record_type: "checkin" },
{ id: "math", name: "数学与数感", icon_key: "calculator", content_entry: "math", record_type: "checkin" },
{ id: "english", name: "英语学习", icon_key: "languages", content_entry: "english", record_type: "checkin" },
{ id: "book", name: "绘本读物", icon_key: "library", content_entry: "book", record_type: "checkin" },
]),
});

function isRecord(value) {
return Boolean(value && typeof value === "object" && !Array.isArray(value));
}

export function defaultContentConfig() {
return {
schema_version: CONTENT_CONFIG_SCHEMA_VERSION,
package_id: FOUNDATION_PACKAGE.id,
package_version: FOUNDATION_PACKAGE.version,
enabled: true,
modules: {
chinese: true,
math: true,
english: true,
book: true,
},
};
}

export function normalizeContentConfig(raw = {}) {
const source = isRecord(raw) ? raw : {};
const defaults = defaultContentConfig();
const modules = {};
for (const module of FOUNDATION_PACKAGE.modules) {
modules[module.id] = source.modules?.[module.id] !== false;
}
return {
schema_version: CONTENT_CONFIG_SCHEMA_VERSION,
package_id: FOUNDATION_PACKAGE.id,
package_version: FOUNDATION_PACKAGE.version,
enabled: source.enabled !== false,
modules,
};
}

export function isPackageEnabled(config) {
return normalizeContentConfig(config).enabled;
}

export function getEnabledModuleIds(config) {
const normalized = normalizeContentConfig(config);
if (!normalized.enabled) return [];
return FOUNDATION_PACKAGE.modules
.filter((module) => normalized.modules[module.id] !== false)
.map((module) => module.id);
}

export function getContentModuleDefinition(moduleId) {
return FOUNDATION_PACKAGE.modules.find((module) => module.id === moduleId) || null;
}

export function setContentPackageEnabled(config, enabled) {
const normalized = normalizeContentConfig(config);
normalized.enabled = Boolean(enabled);
return normalized;
}

export function setContentModuleEnabled(config, moduleId, enabled) {
const normalized = normalizeContentConfig(config);
if (!getContentModuleDefinition(moduleId)) return normalized;
normalized.modules[moduleId] = Boolean(enabled);
if (enabled) normalized.enabled = true;
return normalized;
}
7 changes: 7 additions & 0 deletions src/learning-growth-cloud.js
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,13 @@ export function createGrowthLoopTransport({ client } = {}) {
p_note: payload.note || null,
p_occurred_on: payload.occurred_on || new Date().toISOString().slice(0, 10),
});
case "opening_balance_confirm":
return rpc(client, "learning_confirm_opening_balance", {
p_profile_id: payload.profile_id,
p_balance: payload.delta,
p_request_id: event.request_id,
p_note: payload.note || null,
});
case "reward_upsert":
return upsert(client, "learning_rewards", publicDefinition(payload.reward, REWARD_FIELDS));
case "profile_reward_upsert": {
Expand Down
27 changes: 27 additions & 0 deletions src/learning-growth-loop-controller.js
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import {
applyOpeningBalance,
applyPointAction,
applyPointItemCreation,
applyRedemption,
applyRewardCreation,
closePointPeriod,
createGrowthLoopState,
getOpeningBalance,
mergeGrowthLoopSnapshot,
normalizeGrowthLoopState,
recommendedPointItems,
Expand Down Expand Up @@ -123,6 +125,20 @@ export function createGrowthLoopController({ db } = {}) {
return { ...scope };
}

function openingBalance() {
const entry = getOpeningBalance(snapshot);
return entry ? clone(entry) : null;
}

async function confirmOpeningBalance({ balance, note = "期初积分", request_id = createId() }) {
const result = applyOpeningBalance(snapshot, { scope, balance, note, request_id });
if (result.error) {
return { ...clone(snapshot), error: result.error, entry: result.entry ? clone(result.entry) : null };
}
await persist(result.snapshot, result.events);
return clone(snapshot);
}

function getPointItems({ includeRecommendations = true } = {}) {
if (!includeRecommendations) return clone(snapshot.point_items);
const existingNames = new Set(snapshot.point_items.map((item) => item.name));
Expand Down Expand Up @@ -235,6 +251,11 @@ export function createGrowthLoopController({ db } = {}) {
} else if (event.type === "reward_upsert" && remote?.id) {
const row = next.rewards.find((reward) => reward.id === event.payload.reward?.id);
if (row) Object.assign(row, remote);
} else if (event.type === "opening_balance_confirm") {
const row = next.ledger.find((entry) => entry.request_id === event.request_id);
if (row) {
Object.assign(row, remote || {}, { status: "confirmed" });
}
} else if (event.type === "reward_redeem") {
const redemption = next.redemptions.find((entry) => entry.request_id === event.request_id);
if (redemption) {
Expand All @@ -254,6 +275,10 @@ export function createGrowthLoopController({ db } = {}) {
const row = next.ledger.find((entry) => entry.request_id === event.request_id);
if (row) Object.assign(row, { status: result.status, sync_error: result.error_code || "rejected" });
}
if (event.type === "opening_balance_confirm") {
const row = next.ledger.find((entry) => entry.request_id === event.request_id);
if (row) Object.assign(row, { status: result.status, sync_error: result.error_code || "rejected" });
}
if (event.type === "reward_redeem") {
const redemption = next.redemptions.find((entry) => entry.request_id === event.request_id);
if (redemption) Object.assign(redemption, { status: result.status, sync_error: result.error_code || "rejected" });
Expand Down Expand Up @@ -326,6 +351,8 @@ export function createGrowthLoopController({ db } = {}) {
loadScope,
getSnapshot,
getScope,
openingBalance,
confirmOpeningBalance,
getPointItems,
getRewards,
createPointItem,
Expand Down
49 changes: 49 additions & 0 deletions src/learning-growth-loop.js
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,55 @@ export function getBalance(snapshot) {
return activeLedgerEntries(snapshot).reduce((total, entry) => total + Number(entry.delta || 0), 0);
}

export function getOpeningBalance(snapshot) {
return activeLedgerEntries(snapshot).find((entry) => entry.entry_type === "initial_balance") || null;
}

export function applyOpeningBalance(
current,
{ scope = current.scope, balance, note = "期初积分", request_id = createId("opening") } = {},
) {
const snapshot = normalizeGrowthLoopState(current, scope);
const normalizedScope = normalizeScope(scope);
const delta = Math.trunc(Number(balance));
if (!Number.isFinite(delta) || delta <= 0 || delta > 1000000) {
return { snapshot, events: [], error: "opening_balance_invalid" };
}
const existing = getOpeningBalance(snapshot);
if (existing) {
return { snapshot, events: [], error: "opening_balance_already_confirmed", entry: existing };
}
const ledgerEntry = normalizeLedgerEntry({
id: createId("ledger"),
household_id: normalizedScope.household_id,
profile_id: normalizedScope.profile_id,
point_item_id: null,
delta,
entry_type: "initial_balance",
item_name_snapshot: "期初积分",
note: note || null,
request_id,
occurred_on: new Date().toISOString().slice(0, 10),
status: "pending",
metadata: { opening_balance: true },
}, normalizedScope);
snapshot.ledger.push(ledgerEntry);
return {
snapshot,
ledgerEntry,
events: [localEvent({
type: "opening_balance_confirm",
scope: normalizedScope,
request_id,
payload: {
profile_id: normalizedScope.profile_id,
delta,
note: note || null,
},
})],
};
}

export function getActivePointAction(snapshot, pointItemId, occurredOn) {
const entries = activeLedgerEntries(snapshot).filter(
(entry) => entry.point_item_id === pointItemId && entry.occurred_on === occurredOn,
Expand Down
1 change: 1 addition & 0 deletions src/learning-local-db.js
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ function rehomeOutboxEvent(event, scopeKey, scope) {
payload.profile_point_item.profile_id = scope.profile_id;
}
if (next.type === "point_record" && payload) payload.profile_id = scope.profile_id;
if (next.type === "opening_balance_confirm" && payload) payload.profile_id = scope.profile_id;
if (next.type === "reward_upsert" && payload.reward) {
payload.reward.household_id = scope.household_id;
}
Expand Down
17 changes: 3 additions & 14 deletions src/learning-state-envelope.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { defaultContentConfig } from "./learning-content-package.js";

const LEGACY_STATE_KEYS = new Set([
"checkins",
"extra",
Expand All @@ -7,19 +9,6 @@ const LEGACY_STATE_KEYS = new Set([
"peanutRead",
]);

const DEFAULT_CONTENT_CONFIG = {
schema_version: 1,
package_id: "foundation-v1",
package_version: 1,
enabled: true,
modules: {
chinese: true,
math: true,
english: true,
book: true,
},
};

function isRecord(value) {
return Boolean(value && typeof value === "object" && !Array.isArray(value));
}
Expand Down Expand Up @@ -84,7 +73,7 @@ export function migrateLegacyLearningState(source, scope = {}) {
bookShelf: cloneRecord(legacy.bookShelf),
peanutLog: cloneArray(legacy.peanutLog),
peanutRead: cloneRecord(legacy.peanutRead),
content_config: structuredClone(DEFAULT_CONTENT_CONFIG),
content_config: defaultContentConfig(),
},
legacy: {
points_readonly: cloneRecord(legacy.points),
Expand Down
3 changes: 3 additions & 0 deletions src/learning-state.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { normalizeContentConfig } from "./learning-content-package.js";

const STATE_KEYS = ["checkins", "extra", "points", "bookShelf", "peanutLog", "peanutRead"];

export const CHECKIN_GROUPS = {
Expand Down Expand Up @@ -28,6 +30,7 @@ export function createLearningState(initial = {}) {
bookShelf: cloneRecord(source.bookShelf),
peanutLog: Array.isArray(source.peanutLog) ? structuredClone(source.peanutLog) : [],
peanutRead: cloneRecord(source.peanutRead),
content_config: normalizeContentConfig(source.content_config),
};
}

Expand Down
Loading