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
1 change: 1 addition & 0 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ <h1>影伴</h1>
<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>
<button class="navbtn" data-mod="settings"><span class="ic" data-icon="settings"></span>设置</button>
</nav>

<main class="main" id="main" tabindex="-1"><!-- 动态渲染 --></main>
Expand Down
16 changes: 16 additions & 0 deletions src/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -286,6 +286,22 @@
.reward-card .pts-badge{margin-left:0;}
.reward-redeem{width:auto;padding:8px 10px;font-size:12px;}
.reward-redeem:disabled{opacity:.55;cursor:not-allowed;}
.reward-fulfill{width:auto;padding:8px 10px;font-size:12px;background:#ffd25e;color:#5b3e00;}
.reward-pill{flex:0 0 auto;}
.sound-row{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:10px 0;border-bottom:1px solid var(--line);}
.sound-row:last-child{border-bottom:none;}
.sound-label{font-size:13.5px;font-weight:800;color:var(--ink);}
.sound-switch{min-width:52px;padding:7px 12px;border-radius:999px;border:1px solid var(--line);background:#e8ece8;color:var(--ink-soft);font-family:inherit;font-size:13px;font-weight:800;cursor:pointer;transition:background-color .15s ease,color .15s ease,transform .15s ease;}
.sound-switch:active{transform:scale(.96);}
.sound-switch.on{background:var(--green);color:#fff;border-color:var(--green);}
.sound-range{flex:1;max-width:220px;accent-color:var(--green);}
.sound-events{display:grid;gap:10px;}
.sound-event{border:1px solid var(--line);border-radius:14px;padding:10px 12px;display:grid;gap:8px;}
.sound-event-head{display:flex;align-items:center;justify-content:space-between;gap:10px;}
.sound-event-controls{display:flex;align-items:center;gap:8px;}
.sound-event-controls select{flex:1;min-width:0;border:1px solid var(--line);border-radius:10px;background:var(--card);color:var(--ink);padding:7px 8px;font:inherit;font-size:13px;}
.sound-event-controls select:disabled{opacity:.5;}
.sound-preview{width:auto;padding:7px 12px;font-size:12px;flex:0 0 auto;}
@media (max-width:360px){
.growth-form{grid-template-columns:1fr;}
.growth-form .checkin{width:100%;}
Expand Down
124 changes: 121 additions & 3 deletions src/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import { createIndexedDbLearningDb } from "./learning-local-db.js";
import { createGrowthLoopController } from "./learning-growth-loop-controller.js";
import { ACTIVITY_EVENT_TYPES, activityEventIdFor } from "./learning-analytics.js";
import { getActivePointAction, getBalance, getPointDayTotal, getPointPeriodTotal } from "./learning-growth-loop.js";
import { createSoundEngine, SOUND_EVENTS, SOUND_EVENT_KEYS } from "./learning-sounds.js";

const CHECKIN_MODULES = Object.keys(CHECKIN_GROUPS);

Expand Down Expand Up @@ -136,7 +137,14 @@ const PEANUT_BOOKS = [
const STORE_KEY = "shadow_mate_workbench_v1";

const growthLoopDb = createIndexedDbLearningDb();
const growthLoopController = createGrowthLoopController({ db: growthLoopDb });
const soundEffects = createSoundEngine();
window.soundEffects = soundEffects;
const growthLoopController = createGrowthLoopController({
db: growthLoopDb,
onRewardFulfilled: ({ redemption }) => {
if (redemption?.status === "fulfilled") soundEffects.play("reward_fulfilled");
},
});
let growthLoopSnapshot = growthLoopController.getSnapshot();
let CURRENT_MOD = "home";
window.growthLoop = growthLoopController;
Expand Down Expand Up @@ -245,6 +253,7 @@ function toggleCheckin(mod){
key: mod,
});
save();
if (isChecked(mod)) soundEffects.play("action_completed");
void queueGrowthActivity(
ACTIVITY_EVENT_TYPES.GROWTH_ACTIVITY_RECORDED,
{ source: "checkin", entry_type: "manual" },
Expand Down Expand Up @@ -299,6 +308,8 @@ function togglePoint(itemId, day){
const item = pointItemAt(itemId);
if(!item) return;
const requestId = clientRequestId("point");
const wasOn = pointOn(itemId, day);
const delta = Number(item.default_points ?? item.pts);
void window.growthLoop.recordPoint({ item, occurred_on: dateKeyForDay(day), request_id: requestId }).then(() => {
void queueGrowthActivity(
ACTIVITY_EVENT_TYPES.GROWTH_ACTIVITY_RECORDED,
Expand All @@ -308,6 +319,13 @@ function togglePoint(itemId, day){
void queueGrowthActivity(ACTIVITY_EVENT_TYPES.CORE_ACTIVATION, { source: "point_item" }, "once");
window.cloudSync?.scheduleGrowthLoop?.();
renderPoints();
if (wasOn) {
soundEffects.play("try_again");
} else if (delta < 0) {
soundEffects.play("points_deducted");
} else {
soundEffects.play("points_earned");
}
}).catch((error) => {
console.error("Growth Loop local point write failed:", error);
alert("本机记录没有保存成功,请稍后重试。");
Expand Down Expand Up @@ -410,6 +428,7 @@ function stopActivePlayback() {
}

async function speak(t, button){
soundEffects.setTtsActive(true);
const originalLabel = button?.dataset.label || "听发音";
const voiceHelp = "请在系统设置中安装英语语音包,然后重试";
const showSpeechGuide = () => {
Expand All @@ -426,6 +445,7 @@ async function speak(t, button){
button.after(guideLink);
};
const restore = () => {
soundEffects.setTtsActive(false);
clearSystemTimer();
if (!button) return;
button.innerHTML = buttonContent("volume", originalLabel);
Expand Down Expand Up @@ -805,8 +825,8 @@ function renderMath(){
const v = el("qa").value;
const f = el("qf");
if(v===""){ f.textContent="请先写出答案哦"; f.className="feedback no"; return; }
if(+v===mathAns){ f.innerHTML=`${icon("party")} 答对啦,真棒!`; f.className="feedback ok"; }
else { f.textContent=`再想想~正确答案是 ${mathAns}`; f.className="feedback no"; }
if(+v===mathAns){ f.innerHTML=`${icon("party")} 答对啦,真棒!`; f.className="feedback ok"; soundEffects.play("action_completed"); }
else { f.textContent=`再想想~正确答案是 ${mathAns}`; f.className="feedback no"; soundEffects.play("try_again"); }
};

// 数感:数字填写 1-100 找缺失
Expand Down Expand Up @@ -973,11 +993,19 @@ function renderGrow(){
.filter((item) => item.reward_id === reward.id)
.sort((left, right) => String(right.created_at || "").localeCompare(String(left.created_at || "")))[0];
const status = latest?.status === "pending" ? "待联网确认" : latest?.status === "fulfilled" ? "已兑现" : "";
const canFulfill = latest?.status === "pending" && latest?.confirmed && !latest?.fulfill_requested;
const fulfillRequested = latest?.status === "pending" && latest?.confirmed && latest?.fulfill_requested;
const fulfillAction = canFulfill
? `<button class="checkin reward-fulfill" type="button" data-fulfill-id="${escapeHtml(latest.id)}">待联网确认 · 兑现</button>`
: fulfillRequested
? `<span class="pill reward-pill">兑现已请求</span>`
: "";
return `<div class="reward-card">
<div class="reward-icon">${icon(reward.icon_key || "gift")}</div>
<div class="reward-info"><strong>${escapeHtml(reward.name)}</strong><span>${escapeHtml(reward.description || "家长和孩子一起约定")}</span></div>
<span class="pts-badge">${cost}分</span>
<button class="checkin reward-redeem" type="button" data-reward-id="${escapeHtml(reward.id)}" ${balance < cost ? "disabled" : ""}>${status || "兑换"}</button>
${fulfillAction}
</div>`;
}).join("");
const rewardCard = $(`<div class="card growth-reward-card">
Expand Down Expand Up @@ -1031,6 +1059,19 @@ function renderGrow(){
renderGrow();
};
});
rewardCard.querySelectorAll("[data-fulfill-id]").forEach((button) => {
button.onclick = async () => {
button.disabled = true;
const result = await window.growthLoop.fulfillRedemption({ redemption_id: button.dataset.fulfillId });
if (result.error) {
button.disabled = false;
alert("这个奖励暂时不能兑现,请稍后重试。");
return;
}
window.cloudSync?.scheduleGrowthLoop?.();
renderGrow();
};
});

// 日历式最近记录
let cells="";
Expand Down Expand Up @@ -1421,6 +1462,82 @@ function checkinBtn(mod,label){
return `<button class="checkin ${done?'done':''}" type="button" data-cmod="${mod}" data-clabel="${label}" aria-pressed="${done}">${done?`${icon("checkCircle")} 今日已打卡 · ${label}(点击取消)`:`${icon("check")} 完成今日打卡 · ${label}`}</button>`;
}

/* =========================================================
渲染:音效设置(设备级,仅本机)
========================================================= */
function renderSettings(){
const main = el("main"); main.innerHTML="";
main.appendChild(modTitle("settings","音效设置"));
const settings = soundEffects.getSettings();
main.appendChild($(`
<div class="card">
<h3>${icon("volume")} 音效总开关与音量</h3>
<div class="sound-row">
<span class="sound-label">启用界面音效</span>
<button class="sound-switch ${settings.enabled?"on":""}" type="button" id="snd-master" role="switch" aria-checked="${settings.enabled}">${settings.enabled?"开":"关"}</button>
</div>
<div class="sound-row">
<span class="sound-label" id="snd-volume-label">总音量 ${Math.round(settings.volume*100)}%</span>
<input class="sound-range" type="range" id="snd-volume" min="0" max="100" step="5" value="${Math.round(settings.volume*100)}" aria-label="总音量">
</div>
</div>
`));
const eventsCard = $(`<div class="card"><h3>${icon("list")} 事件音效</h3><div class="sound-events"></div></div>`);
main.appendChild(eventsCard);
const list = eventsCard.querySelector(".sound-events");
for (const key of SOUND_EVENT_KEYS) {
const def = SOUND_EVENTS[key];
const eventSettings = settings.events[key];
const variantOptions = Object.entries(def.variants).map(([variantKey, variant]) =>
`<option value="${variantKey}" ${variantKey===eventSettings.variant?"selected":""}>${variant.name}</option>`
).join("");
list.appendChild($(`
<div class="sound-event" data-event="${key}">
<div class="sound-event-head">
<span class="sound-label">${def.label}</span>
<button class="sound-switch ${eventSettings.enabled?"on":""}" type="button" data-event-enable="${key}" role="switch" aria-checked="${eventSettings.enabled}">${eventSettings.enabled?"开":"关"}</button>
</div>
<div class="sound-event-controls">
<select data-event-variant="${key}" aria-label="${def.label}变体" ${eventSettings.enabled?"":"disabled"}>${variantOptions}</select>
<button class="checkin sound-preview" type="button" data-event-preview="${key}">${icon("play")} 试听</button>
</div>
</div>
`));
}
main.appendChild($(`
<div class="card">
<button class="checkin danger" id="snd-reset" type="button">${icon("rotate")} 恢复默认音效设置</button>
<div class="desc">恢复为默认的总开关、音量与每个事件的变体选择。</div>
</div>
`));
main.appendChild($(`<div class="footer">${icon("settings")} 音效设置只保存在当前设备,不会同步到云端。</div>`));

el("snd-master").onclick = () => {
soundEffects.setEnabled(!soundEffects.getSettings().enabled);
renderSettings();
};
el("snd-volume").oninput = (event) => {
soundEffects.setVolume(Number(event.target.value) / 100);
const label = el("snd-volume-label");
if (label) label.textContent = `总音量 ${Math.round(soundEffects.getSettings().volume*100)}%`;
};
el("snd-reset").onclick = () => {
soundEffects.resetDefaults();
renderSettings();
};
main.querySelectorAll("[data-event-enable]").forEach((button) => button.onclick = () => {
soundEffects.setEventEnabled(button.dataset.eventEnable, !soundEffects.getSettings().events[button.dataset.eventEnable].enabled);
renderSettings();
});
main.querySelectorAll("[data-event-variant]").forEach((select) => select.onchange = () => {
soundEffects.setEventVariant(select.dataset.eventVariant, select.value);
renderSettings();
});
main.querySelectorAll("[data-event-preview]").forEach((button) => button.onclick = () => {
soundEffects.preview(button.dataset.eventPreview);
});
}

/* =========================================================
导航切换
========================================================= */
Expand All @@ -1440,6 +1557,7 @@ function switchMod(mod){
else if(mod==="points") renderPoints();
else if(mod==="grow") renderGrow();
else if(mod==="guide") renderGuide();
else if(mod==="settings") renderSettings();
// 绑定打卡按钮
el("main").querySelectorAll("[data-cmod]").forEach(btn=>{
btn.onclick=()=>{
Expand Down
2 changes: 2 additions & 0 deletions src/icons.js
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import {
Plus,
RefreshCw,
RotateCcw,
Settings,
Sprout,
Star,
Trash2,
Expand Down Expand Up @@ -88,6 +89,7 @@ const ICONS = {
rotate: RotateCcw,
sprout: Sprout,
star: Star,
settings: Settings,
trash: Trash2,
trophy: Trophy,
learner: UserRound,
Expand Down
37 changes: 36 additions & 1 deletion src/learning-growth-loop-controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ function rebindSnapshotScope(input, scope) {
return next;
}

export function createGrowthLoopController({ db } = {}) {
export function createGrowthLoopController({ db, onRewardFulfilled = null } = {}) {
if (!db) throw new Error("growth_loop_local_db_required");
let scope = { household_id: null, profile_id: null };
let scopeKey = scopeKeyForGrowthLoop(scope);
Expand Down Expand Up @@ -188,6 +188,30 @@ export function createGrowthLoopController({ db } = {}) {
return clone(snapshot);
}

async function fulfillRedemption({ redemption_id, request_id = createId() } = {}) {
const redemption = snapshot.redemptions.find((entry) => entry.id === redemption_id);
if (!redemption) return { ...clone(snapshot), error: "redemption_not_found" };
if (redemption.status !== "pending" || redemption.fulfill_requested) {
return { ...clone(snapshot), error: "redemption_not_pending" };
}
const next = normalizeGrowthLoopState(snapshot, scope);
const row = next.redemptions.find((entry) => entry.id === redemption_id);
row.fulfill_requested = true;
await db.appendOutbox({
event_id: createId(),
request_id,
scope_key: scopeKey,
household_id: scope.household_id,
profile_id: scope.profile_id,
type: "redemption_fulfill",
payload: { redemption_id },
});
await db.putSnapshot(scopeKey, next);
snapshot = next;
notify();
return clone(snapshot);
}

async function queueActivity({ event_type, payload = {}, occurred_at, client_version, timezone, event_id }) {
const event = buildActivityEvent({
event_type,
Expand Down Expand Up @@ -239,9 +263,19 @@ export function createGrowthLoopController({ db } = {}) {
const redemption = next.redemptions.find((entry) => entry.request_id === event.request_id);
if (redemption) {
Object.assign(redemption, remote || {}, { status: remote?.status || "pending" });
if (remote?.id) redemption.confirmed = true;
const debit = next.ledger.find((entry) => entry.redemption_id === redemption.id);
if (debit && remote?.id) debit.redemption_id = remote.id;
}
} else if (event.type === "redemption_fulfill") {
const redemption = next.redemptions.find((entry) => entry.id === event.payload.redemption_id);
if (redemption && remote?.status === "fulfilled") {
const alreadyFulfilled = redemption.status === "fulfilled";
Object.assign(redemption, remote, { status: "fulfilled", confirmed: true });
if (!alreadyFulfilled && typeof onRewardFulfilled === "function") {
onRewardFulfilled({ redemption: clone(redemption) });
}
}
}
await db.putSnapshot(scopeKey, next);
snapshot = next;
Expand Down Expand Up @@ -333,6 +367,7 @@ export function createGrowthLoopController({ db } = {}) {
closePeriod,
createReward,
redeemReward,
fulfillRedemption,
queueActivity,
mergeRemote,
sync,
Expand Down
Loading