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
12 changes: 11 additions & 1 deletion .github/PULL_REQUEST_TEMPLATE.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,11 @@

## 检查清单

- [ ] `npm run verify` 通过
- [ ] 已按改动范围选择最小充分的测试层级
- [ ] `npm run test:fast` 通过(如涉及逻辑或代码)
- [ ] 相关 UI/E2E 测试通过(如涉及页面、导航、离线或 PWA)
- [ ] 数据库/Functions/集成测试通过(如涉及对应边界)
- [ ] `npm run test:full` 通过(合并前、发布前或高风险改动)
- [ ] 未提交 `.env`、`.vercel`、secret key 或个人数据
- [ ] 数据库变更附带迁移文件
- [ ] 已检查本次变更涉及的 README、公开 docs、CHANGELOG、发布说明及隐私/安全文档,并已同步实现差异
Expand All @@ -22,3 +26,9 @@
- [ ] 如包含外部贡献或第三方代码,已阅读 [THIRD_PARTY_NOTICES.md](../THIRD_PARTY_NOTICES.md),并在 PR 中说明来源与授权状态
- [ ] 已运行 `npm run public:check`,并复查暂存区没有内部、敏感或不必要文件
- [ ] 如果这是发布准备变更,已确认 `release-gate.config.json`、版本文件和发布说明同步

## 验证记录

- 选定层级:
- 命令与结果:
- 未运行或被环境阻塞的检查及原因:
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -62,10 +62,13 @@ jobs:
run: |
status="$(npx supabase status --output env 2>/dev/null)"
api_url="$(printf '%s\n' "$status" | sed -n 's/^API_URL=//p' | tr -d '"')"
db_url="$(printf '%s\n' "$status" | sed -n 's/^DB_URL=//p' | tr -d '"')"
publishable_key="$(printf '%s\n' "$status" | sed -n 's/^ANON_KEY=//p' | tr -d '"')"
test -n "$api_url"
test -n "$db_url"
test -n "$publishable_key"
echo "VITE_SUPABASE_URL=$api_url" >> "$GITHUB_ENV"
echo "SHADOW_MATE_TEST_DB_URL=$db_url" >> "$GITHUB_ENV"
echo "VITE_SUPABASE_PUBLISHABLE_KEY=$publishable_key" >> "$GITHUB_ENV"
echo "E2E_REAL_SUPABASE=1" >> "$GITHUB_ENV"
- name: Run database tests
Expand Down
22 changes: 18 additions & 4 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,17 +6,31 @@
npm.cmd ci
git config core.hooksPath .githooks
git config user.email "YOUR_GITHUB_NOREPLY_ADDRESS"
npm.cmd run verify
npm.cmd run test:fast
```

Get the noreply address from GitHub **Settings → Emails**. Do not use a personal or work mailbox in public commit metadata.

Maintainers may create an ignored `.security-local-denylist` file with one private term per line. The security check scans tracked and untracked candidate files without publishing the denylist itself.

## 测试范围与分层

先写清本次改动的范围、明确不做什么、验收条件和受影响边界,再按风险选择最小充分的验证层级。开发循环不要求每次小改动都运行全量测试;合并、发布和高风险边界仍必须经过完整门禁。

| 层级 | 适用场景 | 命令 |
| --- | --- | --- |
| 静态 | 文档、文案、低风险 CSS 或静态检查 | `npm run check` |
| 快速 | 纯逻辑、数据模型、控制器 | `npm run test:fast` |
| 页面 | 导航、设置、PWA、离线和可见交互 | `npm run test:ui`,或运行指定 E2E 文件 |
| 集成 | Supabase schema/RLS、Functions、认证、同步、导出/删除 | `npm run test:db`、`npm run test:functions`,以及受影响的 E2E |
| 完整 | 合并前、发布前、依赖/公开资源或高风险边界 | `npm run test:full` |

`test:fast` 当前包含全部 unit test 和 `check`,它是比浏览器/数据库测试更快的项目级入口,但不是 changed-only 测试。`verify` 负责公开范围、安全检查、静态检查、构建和覆盖率,不包含数据库、Functions 或 E2E;`test:full` 只在合并、发布或高风险边界运行。PR 必须记录实际选择的层级、命令、结果,以及未运行或被环境阻塞的检查。

## Required checks

- Use a branch and pull request; do not push directly to `main`.
- Run `npm run verify` before pushing.
- Before pushing, run the smallest sufficient layer for the changed surface; source/build/public-resource changes require `npm run verify`, with database, Functions and E2E layers added when affected. Run `npm run test:full` before merge or release.
- For a release tag, run `npm run build` followed by `npm run release:check`; the tag workflow repeats this against the final archive.
- Commit `package-lock.json` and pin dependency versions.
- Add explicit PostgreSQL grants and RLS policies in the same migration.
Expand All @@ -37,7 +51,7 @@ Maintainers may create an ignored `.security-local-denylist` file with one priva
- 检查本次 `git diff` 涉及的用户行为、数据模型、迁移、配置、测试命令、覆盖率、版本号和发布状态。
- 按影响范围同步 `README.md`、公开 `docs/`、`CHANGELOG.md`、`RELEASE_NOTES.md`、隐私/安全文档和 PR 说明;内部计划、法律记录和发布闸门不得放入公开目录。
- 代码、测试、迁移、配置和对应文档必须作为同一项工作提交并推送,禁止明知文档过期而先提交代码、之后再补文档。
- 提交前运行 `npm run public:check`、`git diff --cached --check` 和 `npm run verify`,并逐项复查 `git diff --cached --name-status` 与 `git diff --cached`,确认没有把内部、敏感或不必要文件加入提交。
- 提交前运行 `npm run public:check`、`git diff --cached --check` 和与改动范围匹配的测试层,并逐项复查 `git diff --cached --name-status` 与 `git diff --cached`,确认没有把内部、敏感或不必要文件加入提交;合并前或发布前补齐 `npm run test:full`
- 推送前重新核对远端仓库可见性、目标分支、PR base/head 和 PR 描述;任何不确定的文件先移出暂存区,不要“先提交再解释”。

## Release 闸门
Expand All @@ -59,7 +73,7 @@ Release 必须在 Tag 上执行,不把普通 PR 当作发布验收:

## GitHub 协作流程

- 本地先运行 `npm.cmd run verify`;涉及端到端流程时,再运行 `npm.cmd run test:e2e`。
- 本地先运行 `npm.cmd run test:fast`;涉及页面交互时,再运行 `npm.cmd run test:ui`;涉及数据库、认证或同步时,补充对应集成测试。合并前按风险矩阵运行 `npm.cmd run test:full`。
- 使用分支提交并推送,保持现有 SSH 远程仓库配置;不需要为每次 PR 重复配置 GitHub CLI。
- 分支推送后,优先使用已连接的 GitHub 插件创建、查看、Review 和合并 PR,避免通过浏览器重复填写表单。
- PR 作者不能批准自己的 PR;需要独立 Review 时邀请其他协作者,管理员按分支保护规则完成合并。
Expand Down
40 changes: 33 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,20 +82,38 @@ npm.cmd run dev
```powershell
npm.cmd run check
npm.cmd run build
npm.cmd run test:unit
npm.cmd run test:e2e
npm.cmd run test:fast
npm.cmd run test:ui
```

需要本地数据库测试时,先启动 Docker Desktop:

```powershell
supabase start
npm.cmd run test:db
supabase db lint --local --schema public --level warning --fail-on error
npm run supabase:local:start
npm run test:db
```

`supabase:local:start` 会转到同级 `shadow-size/merchant-admin`,启动共享本地 Supabase,并按 Shadow Portal 控制面的 SHA-256 校验结果加载 Shadow Mate 的 `learning_*` 业务 schema。控制面历史快照只会应用到 `127.0.0.1:54322`,不会复制到生产迁移目录,也不会连接生产数据库。

如果要验收登录、找回密码或其他 Edge Function,再开一个终端运行:

```powershell
npm run supabase:local:functions:serve
```

该命令会准备共享函数覆盖层并以前台方式运行本地函数服务;关闭该终端就会停止函数服务。

如果要对共享本地数据库执行 lint,请在 merchant-admin 目录运行:

```powershell
cd ../shadow-size/merchant-admin
npx supabase db lint --local --schema public --level warning --fail-on error
```

`test:coverage` 覆盖核心纯函数、学习状态机和防重复操作锁,语句、分支、函数和行覆盖率门槛均为 80%。`test:e2e` 覆盖离线导航、打卡、积分、日历、家庭空间、重复点击保护、邮箱验证码/密码登录、找回密码、数据生命周期和云端冲突限次重试;真实 Supabase E2E 需要额外配置环境变量。

日常开发按改动范围选择最小充分的检查:页面改动运行目标 UI 测试,数据库/认证/同步改动补充对应集成测试;合并或发布前运行 `npm.cmd run test:full`。`test:fast` 是静态检查加全部 unit test,不是 changed-only 测试。

## 工作方式

```text
Expand Down Expand Up @@ -124,7 +142,7 @@ src/learning-state.js 学习状态机与四个模块的打卡分组
src/cloud.js 验证码/密码登录、家庭空间、同步、导出与删除
src/action-lock.js 全局快速连点拦截与异步操作单次执行锁
src/icons.js Lucide 图标渲染与图标 hydration
supabase/migrations/ 家庭数据、RLS、生命周期和删除权限
supabase/migrations/ Shadow Mate 的 schema 提案与隔离 CI 测试副本
supabase/functions/ 账号级服务端删除
tests/unit/ 纯函数与学习状态机测试
tests/e2e/ 离线、云端和数据生命周期测试
Expand All @@ -134,7 +152,15 @@ tests/e2e/ 离线、云端和数据生命周期测试

当前部署配置位于 `src/config.js`,浏览器端只使用 publishable key。真正的数据隔离由 Supabase RLS、家庭成员关系和产品 ID 共同完成;绝不能把 secret key 或 `service_role` key 放进仓库。

数据库迁移位于 `supabase/migrations/`,包括:
### 共享 Supabase 与迁移边界

影伴接入共享 Supabase 后,日常本地验收必须通过 `npm run supabase:local:start`,由同级 `shadow-size/merchant-admin` 启动共享本地实例,并加载经 Shadow Portal 控制面校验的 Shadow Mate `learning_*` schema。需要验收 Auth 或 Edge Functions 时,再在第二个终端运行 `npm run supabase:local:functions:serve`。

仓库中的 `supabase/migrations/` 仍用于保存与代码同步的迁移提案和隔离 CI 测试副本,不是共享生产库的唯一发布目录。共享生产迁移的 canonical 文件、审批、发布和台账由 `shadow-portal/supabase/control-plane` 管理;不要在本仓库直接执行生产 `db push`、`migration repair` 或 linked SQL。

`supabase/config.toml` 的独立端口和迁移配置仅供 CI/隔离测试使用。不要在影伴仓库根目录直接运行裸 `supabase start` 来代替共享本地启动。

数据库迁移提案包括:

- 项目登记和共享多租户兼容性
- 家庭、成员、学习者和学习状态表
Expand Down
7 changes: 6 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,13 @@
"verify": "npm run public:check && npm run security:check && npm run check && npm run build && node scripts/check-build.mjs && npm run test:coverage",
"test": "vitest run",
"test:unit": "vitest run tests/unit",
"test:fast": "npm run check && npm run test:unit",
"test:ui": "node scripts/run-e2e.mjs tests/e2e/offline.spec.js",
"test:full": "npm run verify && npm run test:db && npm run test:functions && npm run test:e2e && npm run release:check",
"test:coverage": "vitest run --coverage",
"test:db": "supabase test db --local",
"supabase:local:start": "node scripts/start-shared-supabase.mjs",
"supabase:local:functions:serve": "node scripts/serve-shared-functions.mjs",
"test:db": "node scripts/test-shared-db.mjs",
"test:functions": "node scripts/test-delete-account-guard.mjs",
"test:e2e": "node scripts/run-e2e.mjs"
},
Expand Down
21 changes: 21 additions & 0 deletions scripts/serve-shared-functions.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
#!/usr/bin/env node

import { execFileSync } from 'node:child_process'
import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'

const shadowMateRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
const merchantAdminRoot = path.resolve(shadowMateRoot, '..', 'shadow-size', 'merchant-admin')

if (!fs.existsSync(path.join(merchantAdminRoot, 'package.json'))) {
throw new Error(
`找不到共享本地 Supabase 控制仓库:${merchantAdminRoot}\n请确认 shadow-mate、shadow-size 位于同一个 VibeCoding 目录。`,
)
}

const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm'
execFileSync(npmCommand, ['run', 'supabase:local:functions:serve'], {
cwd: merchantAdminRoot,
stdio: 'inherit',
})
21 changes: 21 additions & 0 deletions scripts/start-shared-supabase.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
#!/usr/bin/env node

import { execFileSync } from 'node:child_process'
import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'

const shadowMateRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
const merchantAdminRoot = path.resolve(shadowMateRoot, '..', 'shadow-size', 'merchant-admin')

if (!fs.existsSync(path.join(merchantAdminRoot, 'package.json'))) {
throw new Error(
`找不到共享本地 Supabase 控制仓库:${merchantAdminRoot}\n请确认 shadow-mate、shadow-size 位于同一个 VibeCoding 目录。`,
)
}

const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm'
execFileSync(npmCommand, ['run', 'supabase:local:start'], {
cwd: merchantAdminRoot,
stdio: 'inherit',
})
33 changes: 32 additions & 1 deletion scripts/test-delete-account-guard.mjs
Original file line number Diff line number Diff line change
@@ -1,8 +1,39 @@
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";

const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");

function loadLocalEnv() {
const envPath = path.join(projectRoot, ".env.local");
if (!fs.existsSync(envPath)) return;

for (const line of fs.readFileSync(envPath, "utf8").split(/\r?\n/)) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) continue;

const separator = trimmed.indexOf("=");
if (separator < 1) continue;

const key = trimmed.slice(0, separator).trim();
const value = trimmed
.slice(separator + 1)
.trim()
.replace(/^(["'])(.*)\1$/, "$2");

if (!process.env[key]) process.env[key] = value;
}
}

loadLocalEnv();

const supabaseUrl = process.env.VITE_SUPABASE_URL;
const publishableKey = process.env.VITE_SUPABASE_PUBLISHABLE_KEY;

if (!supabaseUrl || !publishableKey) {
throw new Error("VITE_SUPABASE_URL and VITE_SUPABASE_PUBLISHABLE_KEY are required");
throw new Error(
"VITE_SUPABASE_URL and VITE_SUPABASE_PUBLISHABLE_KEY are required; set them in the environment or .env.local",
);
}

const email = `delete-guard-${Date.now()}@example.test`;
Expand Down
109 changes: 109 additions & 0 deletions scripts/test-shared-db.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
#!/usr/bin/env node

import { execFileSync } from 'node:child_process'
import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath, pathToFileURL } from 'node:url'

const shadowMateRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
const merchantAdminRoot = path.resolve(shadowMateRoot, '..', 'shadow-size', 'merchant-admin')
const testsDir = path.join(shadowMateRoot, 'supabase', 'tests')

function collectTestFiles() {
return fs
.readdirSync(testsDir)
.filter((entry) => entry.endsWith('.sql'))
.sort()
.map((entry) => path.join(testsDir, entry))
}

function validateLocalDatabaseUrl(databaseUrl, source) {
let parsedUrl
try {
parsedUrl = new URL(databaseUrl)
} catch {
parsedUrl = null
}

if (!parsedUrl || parsedUrl.protocol !== 'postgresql:' || parsedUrl.hostname !== '127.0.0.1') {
throw new Error(
`${source} 不是 loopback 本地 PostgreSQL 地址;为避免测试误连生产库,已停止。`,
)
}

return databaseUrl
}

function readDatabaseUrlFromSupabase(root, source) {
const status = execFileSync(
'npx',
['supabase', 'status', '-o', 'env'],
{
cwd: root,
env: { ...process.env, SUPABASE_TELEMETRY_DISABLED: '1' },
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
},
)
const line = status.split(/\r?\n/).find((entry) => entry.startsWith('DB_URL='))
const databaseUrl = line
?.slice('DB_URL='.length)
.trim()
.replace(/^['"]|['"]$/g, '')

if (!databaseUrl) {
throw new Error(`${source} 没有返回 DB_URL;请先启动本地 Supabase。`)
}

return validateLocalDatabaseUrl(databaseUrl, source)
}

function getDatabaseUrl() {
if (process.env.SHADOW_MATE_TEST_DB_URL) {
return validateLocalDatabaseUrl(process.env.SHADOW_MATE_TEST_DB_URL, 'SHADOW_MATE_TEST_DB_URL')
}

if (fs.existsSync(path.join(merchantAdminRoot, 'package.json'))) {
return readDatabaseUrlFromSupabase(merchantAdminRoot, 'merchant-admin 本地 Supabase')
}

throw new Error(
`找不到 merchant-admin:${merchantAdminRoot}\n本地测试请运行 npm run supabase:local:start;CI 若使用隔离数据库,必须显式设置 SHADOW_MATE_TEST_DB_URL。`,
)
}

function buildTestDatabaseUrl(databaseUrl) {
const parsedUrl = new URL(databaseUrl)
parsedUrl.searchParams.set('sslmode', 'disable')
return parsedUrl.toString()
}

export { buildTestDatabaseUrl }

if (process.argv[1] && pathToFileURL(process.argv[1]).href === import.meta.url) {
const databaseUrl = getDatabaseUrl()
const testDatabaseUrl = buildTestDatabaseUrl(databaseUrl)
const testFiles = collectTestFiles()

if (testFiles.length === 0) {
throw new Error(`在 ${testsDir} 中没有找到任何 SQL 测试文件。`)
}

console.log(`🧪 运行 ${testFiles.length} 个 pgTAP 测试文件(目标:loopback 本地数据库)`)
execFileSync(
'npx',
[
'supabase',
'test',
'db',
'--db-url',
testDatabaseUrl,
...testFiles,
],
{
cwd: shadowMateRoot,
env: { ...process.env, SUPABASE_TELEMETRY_DISABLED: '1' },
stdio: 'inherit',
},
)
}
20 changes: 20 additions & 0 deletions src/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -271,6 +271,26 @@
.pts-card.sub .pts-toggle{background:#e08a8a;}
.pts-card.sub.done .pts-toggle{background:#d9534f;}

.growth-form{display:grid;grid-template-columns:minmax(0,1fr) 120px auto;gap:8px;align-items:end;margin-top:10px;}
.growth-form label{display:flex;flex-direction:column;gap:5px;color:var(--green-deep);font-size:12px;font-weight:800;}
.growth-form input{width:100%;box-sizing:border-box;border:1px solid var(--line);border-radius:12px;background:var(--card);color:var(--ink);padding:10px 11px;font:inherit;font-size:14px;}
.growth-form .checkin{width:auto;white-space:nowrap;padding:10px 14px;font-size:13px;}
.growth-custom-card{background:linear-gradient(135deg,var(--card),var(--green-soft));}
.growth-reward-card{background:linear-gradient(135deg,var(--card),#fff7dc);}
.reward-list{margin-top:14px;display:grid;gap:9px;}
.reward-card{display:flex;align-items:center;gap:9px;border:1px solid var(--line);border-radius:16px;background:var(--card);padding:9px 10px;}
.reward-icon{width:34px;height:34px;border-radius:12px;background:#fff2bc;color:#a26c00;display:flex;align-items:center;justify-content:center;flex:0 0 auto;}
.reward-info{display:flex;flex:1;min-width:0;flex-direction:column;gap:2px;}
.reward-info strong{font-size:13px;color:var(--ink);}
.reward-info span{font-size:11px;color:var(--ink-soft);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
.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;}
@media (max-width:360px){
.growth-form{grid-template-columns:1fr;}
.growth-form .checkin{width:100%;}
}

.cal{display:grid;grid-template-columns:repeat(7,minmax(0,1fr));gap:6px;}
.cal-chip{width:100%;height:auto;aspect-ratio:1;border-radius:10px;display:flex;align-items:center;justify-content:center;font-size:14px;font-weight:800;background:#f1f7f2;border:2px solid transparent;cursor:pointer;color:var(--ink-soft);transition:transform .15s ease,background-color .15s ease,border-color .15s ease,box-shadow .15s ease;font-family:inherit;appearance:none;}
.cal-chip:active{transform:scale(.96);}
Expand Down
Loading
Loading