保存视觉审核与文档流迁移进度

This commit is contained in:
rain
2026-07-20 17:23:09 +08:00
parent 5a31a75da0
commit 01d246c47b
59 changed files with 3615 additions and 378 deletions
+53 -14
View File
@@ -1,14 +1,53 @@
/tmp/
/unpackage/
/docs/superpowers/specs/design/
/docs/design/screens/runtime/
/tmp-g01-icon-audit.png
/node_modules/
**/node_modules/
/design-pipeline/generated/
/docs/design/assets/a01-vnext/candidates/
/docs/design/assets/a01-vnext/review/
/.superpowers/
/runtime/
**/__pycache__/
*.py[cod]
######################################################################
# Build Tools
.gradle
/build/
!gradle/wrapper/gradle-wrapper.jar
target/
!.mvn/wrapper/maven-wrapper.jar
######################################################################
# IDE
### STS ###
.apt_generated
.classpath
.factorypath
.project
.settings
.springBeans
### IntelliJ IDEA ###
.idea
*.iws
*.iml
*.ipr
### JRebel ###
rebel.xml
### NetBeans ###
nbproject/private/
build/*
nbbuild/
nbdist/
.nb-gradle/
######################################################################
# Others
*.log
*.log.gz
*.xml.versionsBackup
*.swp
!*/build/*.java
!*/build/*.html
!*/build/*.xml
.flattened-pom.xml
/.maven-home/.msp/incrementMark
robots.txt
sitemap.xml
+9
View File
@@ -0,0 +1,9 @@
{
"version" : "1.0",
"configurations" : [
{
"playground" : "standard",
"type" : "uni-app:app-android"
}
]
}
+38 -22
View File
@@ -142,7 +142,7 @@
block
:type="moduleState === 'error' ? 'secondary' : 'primary'"
:label="stateCopy.action"
@click="moduleState = 'ready'"
@click="handleStateAction"
/>
</view>
</view>
@@ -212,9 +212,11 @@ const normalizedNote = computed(() =>
);
const normalizedSectionCopy = (copy) =>
String(copy || "").replace(/待接入|功能待接入/g, "后续功能阶段开放");
const stateCopy = computed(
() =>
({
const stateCopy = computed(() => {
const pageState = page.value.states?.[moduleState.value];
if (pageState) return pageState;
return (
{
empty: {
eyebrow: "暂无内容",
title: `还没有${page.value.title}记录`,
@@ -233,8 +235,16 @@ const stateCopy = computed(
copy: "请稍后重新进入,现有资料不会受到影响。",
action: "重新查看",
},
})[moduleState.value] || {},
}[moduleState.value] || {}
);
});
const handleStateAction = () => {
if (stateCopy.value.actionMode === "back") {
uni.navigateBack();
return;
}
moduleState.value = "ready";
};
const completeAction = () => {
moduleState.value = "success";
};
@@ -308,8 +318,14 @@ onUnmounted(() => {
}
.form-row {
position: relative;
height: 84rpx;
display: grid;
grid-template-columns: auto minmax(0, 1fr);
min-height: 84rpx;
align-items: center;
gap: 22rpx;
margin-top: 14rpx;
padding: 14rpx 26rpx;
box-sizing: border-box;
}
.form-row image,
.detail-card image,
@@ -320,27 +336,24 @@ onUnmounted(() => {
inset: 0;
width: 100%;
height: 100%;
pointer-events: none;
}
.form-row > text {
position: absolute;
top: 28rpx;
left: 26rpx;
position: relative;
z-index: 1;
color: $ink;
font-size: 24rpx;
font-weight: 700;
}
.form-row input {
position: absolute;
top: 0;
right: 22rpx;
bottom: 0;
left: 178rpx;
position: relative;
z-index: 1;
height: 84rpx;
width: 100%;
min-width: 0;
height: 56rpx;
color: $ink;
font-size: 23rpx;
line-height: 84rpx;
text-align: right;
}
.form-placeholder {
color: #9e8e79;
@@ -448,13 +461,18 @@ onUnmounted(() => {
}
.settings-row {
position: relative;
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
min-height: 94rpx;
align-items: center;
gap: 20rpx;
margin-top: 14rpx;
padding: 20rpx 28rpx 18rpx 26rpx;
box-sizing: border-box;
}
.settings-row > view {
position: relative;
z-index: 1;
padding: 20rpx 98rpx 18rpx 26rpx;
}
.settings-row > view text:first-child {
color: $ink;
@@ -467,9 +485,7 @@ onUnmounted(() => {
font-size: 21rpx;
}
.settings-row > text {
position: absolute;
top: 34rpx;
right: 28rpx;
position: relative;
z-index: 1;
color: $brand-red;
font-size: 21rpx;
@@ -540,8 +556,8 @@ onUnmounted(() => {
padding-right: 20rpx;
padding-left: 20rpx;
}
.form-row input {
left: 162rpx;
.form-row {
gap: 14rpx;
}
.status-card__body {
padding-right: 44rpx;
+16 -2
View File
@@ -1,5 +1,6 @@
<!-- 公共组件根页朱砂页头与普通返回页头根页背景图层由统一基础资产提供 -->
<template>
<view class="page-header-slot" :class="{ 'page-header-slot--root': root }">
<view class="page-header" :class="{ 'page-header--root': root }">
<!-- 根页头部使用不透明朱砂底图与透明祠堂线稿两层完整资产 -->
<image
@@ -56,6 +57,7 @@
}}</view>
</view>
</view>
</view>
</template>
<script setup>
@@ -70,7 +72,21 @@ const goBack = () => uni.navigateBack();
</script>
<style scoped lang="scss">
.page-header-slot {
height: 104rpx;
flex: none;
}
.page-header-slot--root {
height: calc(124rpx + var(--status-bar-height, 0px));
}
.page-header {
position: fixed;
top: 0;
right: 0;
left: 0;
z-index: 30;
display: flex;
align-items: center;
height: 104rpx;
@@ -81,8 +97,6 @@ const goBack = () => uni.navigateBack();
}
.page-header--root {
position: relative;
z-index: 3;
height: calc(124rpx + var(--status-bar-height, 0px));
padding-top: var(--status-bar-height, 0px);
background-color: #b52e22;
+22 -15
View File
@@ -219,19 +219,22 @@ const showConflictHelp = () => {
position: relative;
z-index: 2;
width: calc(100% - 32rpx);
height: min(640px, calc((100vw - 16px) * 1.48));
min-height: min(640px, calc((100vw - 16px) * 1.48));
margin: 18rpx auto 0;
padding: 7.5% 8%;
box-sizing: border-box;
}
.member-form-panel__skin {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
pointer-events: none;
}
.member-form,
.member-form-result {
position: absolute;
inset: 7.5% 8%;
position: relative;
z-index: 1;
}
.member-form__eyebrow {
display: block;
@@ -256,35 +259,38 @@ const showConflictHelp = () => {
}
.member-field {
position: relative;
height: 78rpx;
display: grid;
grid-template-columns: auto minmax(0, 1fr);
min-height: 78rpx;
align-items: center;
gap: 20rpx;
margin-top: 12rpx;
padding: 12rpx 22rpx;
box-sizing: border-box;
}
.member-field image {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
pointer-events: none;
}
.member-field > text {
position: absolute;
top: 27rpx;
left: 22rpx;
position: relative;
z-index: 1;
color: $ink;
font-size: 24rpx;
font-weight: 700;
}
.member-field input {
position: absolute;
top: 0;
right: 18rpx;
bottom: 0;
left: 164rpx;
position: relative;
z-index: 1;
height: 78rpx;
width: 100%;
min-width: 0;
height: 54rpx;
color: $ink;
font-size: 24rpx;
line-height: 78rpx;
text-align: right;
}
.member-placeholder {
color: #a79884;
@@ -308,6 +314,7 @@ const showConflictHelp = () => {
inset: 0;
width: 100%;
height: 100%;
pointer-events: none;
}
.member-form-action text {
position: relative;
@@ -324,7 +331,7 @@ const showConflictHelp = () => {
color: $ink;
}
.member-form-result {
top: 29%;
margin-top: 21.5%;
text-align: center;
}
.member-form-result .member-form__eyebrow {
+17 -7
View File
@@ -3,15 +3,25 @@ export const pageCatalog = {
f03: { number: 'F-03', title: '动态详情', subtitle: '一段属于家人的共同记忆', template: 'detail', sections: [['端午家宴', '今年端午全家相聚,留下了许多温暖照片。'], ['家人评论', '愿家族和睦兴旺,岁岁平安。']], action: '写下评论' },
f04: { number: 'F-04', title: '谱文', subtitle: '收录家族文章、家训与往事', template: 'list', sections: [['家风家训', '代代相传的处世之道'], ['家族往事', '珍贵的口述与文字记录'], ['族谱序言', '一部家谱的起源']], action: '新建谱文' },
f05: { number: 'F-05', title: '谱文详情', subtitle: '家族文字档案', template: 'detail', sections: [['家风家训', '孝友传家,勤俭立业;敬祖睦宗,诚实待人。'], ['收录时间', '2024 年 5 月 12 日']], action: '收藏谱文' },
f06: { number: 'F-06', title: '编辑谱文', subtitle: '把值得传承的故事写下来', template: 'form', fields: ['文章标题', '文章分类', '正文内容'], action: '保存谱文', note: '草稿和发布功能待接入' },
f05: {
number: 'F-05',
title: '谱文详情',
subtitle: '家族文字档案',
template: 'detail',
sections: [['家风家训', '孝友传家,勤俭立业;敬祖睦宗,诚实待人。'], ['收录时间', '2024 年 5 月 12 日']],
action: '收藏谱文',
states: {
empty: {
eyebrow: '谱文已失效',
title: '这篇谱文已无法查看',
copy: '内容可能已被作者删除或取消公开,请返回谱文列表查看其他内容。',
action: '返回谱文列表',
actionMode: 'back',
},
},
},
f07: { number: 'F-07', title: '家族相册', subtitle: '让每一张照片都回到家人身边', template: 'list', sections: [['2024 春节团圆', '18 张照片 · 更新于今天'], ['祖居旧影', '32 张照片 · 家族档案'], ['儿童成长', '46 张照片 · 持续更新']], action: '新建相册' },
f08: { number: 'F-08', title: '相册详情', subtitle: '2024 春节团圆', template: 'detail', sections: [['相册说明', '一家人围坐团圆,留下新春最温暖的瞬间。'], ['照片墙', '共 18 张 · 点击照片可预览']], action: '添加照片' },
f09: { number: 'F-09', title: '上传照片', subtitle: '选择照片并补充拍摄说明', template: 'form', fields: ['选择照片', '照片说明', '所属相册'], action: '开始上传', note: '上传失败时可在此重新尝试' },
f10: { number: 'F-10', title: '家族视频', subtitle: '记录会动的家族记忆', template: 'status', badge: '影', lead: '视频服务暂未开放', note: '开放后可在这里浏览家族影像与纪念视频', action: '返回家族首页' },
r01: { number: 'R-01', title: '人物录', subtitle: '记录家族中值得铭记的人', template: 'list', sections: [['汤文正', '家谱管理员 · 第 18 世'], ['汤淑华', '家族长辈 · 第 17 世'], ['汤文清', '青年代表 · 第 19 世']], action: '新建人物' },
r02: { number: 'R-02', title: '人物详情', subtitle: '汤文正', template: 'detail', sections: [['人物小传', '勤于修谱,常年整理族中旧照片与口述资料。'], ['家族印记', '参与维护汤氏家谱与家风家训。']], action: '编辑人物' },
r03: { number: 'R-03', title: '贺礼簿', subtitle: '记录每一份家人之间的心意', template: 'list', sections: [['新春贺礼', '2024 年春节 · 12 条记录'], ['寿宴礼单', '汤老先生八十寿辰'], ['添丁祝福', '家族新成员的祝愿']], action: '新增贺礼' },
r04: { number: 'R-04', title: '编辑贺礼', subtitle: '如实留下礼仪往来记录', template: 'form', fields: ['贺礼名称', '赠送人', '日期', '备注'], action: '保存贺礼', note: '删除确认功能待接入' },
r05: { number: 'R-05', title: '礼仪活动', subtitle: '家族的重要时刻与礼序', template: 'list', sections: [['清明祭祖', '2024 年 4 月 4 日'], ['中秋家宴', '2024 年 9 月 17 日'], ['新春团拜', '2025 年 1 月 29 日']], action: '新建礼仪' },
@@ -0,0 +1,73 @@
# 全项目文档流迁移实施计划
> **执行要求:** 使用 `superpowers:executing-plans` 当前会话内联执行。用户禁止多代理、worktree 及全部 Git 写操作;不执行提交步骤。
**目标:** 除用户指定 fixed 的顶部/底部导航与真实弹层外,清除全项目页面内容的脱离文档流定位,并保持现有业务状态与视觉语言。
**架构:** 以一个静态合同和精确 JSON 白名单为单一规则所有者;先完成 R02 样板,再迁移共享组件和模块页面。每批通过现有 CDP smoke、四档响应式及同尺寸截图后进入下一批。
**技术栈:** uni-app Vue 3、SCSS、PowerShell、Node.js、Chrome CDP。
## 全局约束
- 禁止普通内容使用 `absolute/fixed/sticky`、负外边距或以位移 transform 承担布局;按钮按压等不改变排版的交互微动效可以保留。
- 允许定位的职责为固定顶部/底部导航、独立视口背景、真实弹层,以及受明确父容器约束的纯装饰、角标、红点和关系线,必须精确白名单。
- 不删除或重绘现有资产;装饰图优先改作 background/border-image,只有确需局部叠放时保留 absolute。
- 四档为 320×568、360×640、360×800、412×915。
- 每次只修改当前批次,验证失败立即停止扩散。
---
### 任务 1:建立全项目失败合同
**文件:**
- 新建:`tests/document-flow-position-allowlist.json`
- 新建:`tests/document-flow-position-contract.ps1`
- [ ] 白名单先登记 `PageHeader.vue` 的固定顶部栏、`AppTabbar.vue` 的固定底部栏、`AppDialog.vue` 的弹窗层/皮肤和 `AppToast.vue` 的 Toast;其他页面本地弹层在迁移到对应批次时逐项审计后登记。
- [ ] 合同扫描 `pages/**/*.vue``components/**/*.vue` 的 style 块,报告不在精确文件+选择器白名单内的 `absolute/fixed/sticky`
- [ ] 合同同时禁止普通选择器出现负 margin 和 `transform: translate(...)` 布局。
- [ ] 运行合同并保存初始 37 文件违规清单;预期 FAIL,不放宽规则。
### 任务 2R02 文档流样板
**文件:**
- 修改:`pages/records/r02-person-detail.vue`
- 修改:`tests/r02-person-detail-contract.ps1`
- 修改:`tests/r02-person-detail-runtime-smoke.js`
- [ ] 先扩展 R02 合同,禁止文件内 `absolute/fixed/sticky` 并要求题签、卡片、短字段、长字段和状态卡使用 background + flex/grid。
- [ ] 运行确认 RED。
- [ ] 删除装饰 `<image>` 叠层;对应容器使用 records 资产作为背景。
- [ ] 题签 copy、字段 label/input、textarea、状态文案全部使用文档流;容器高度由内容和 min-height 控制。
- [ ] 运行 R02 合同、入口交互 smoke、四档 smoke,捕获 412×915 详情和编辑态供复核。
### 任务 3:共享基础组件
**文件:** `components/AppButton.vue``PageHeader.vue``AppTabbar.vue``ModulePageBackground.vue``GenealogyPageBackground.vue``GenealogyCard.vue`
- [ ] 每个组件先新增聚焦合同并确认 RED。
- [ ] PageHeader 与 AppTabbar 保留 fixed,并分别提供正常流顶部占位和页面底部避让;按钮/Header 的局部装饰、角标与红点逐项判断,能用背景则转背景,必须叠放则精确白名单;独立视口背景层可保留 fixed。
- [ ] GenealogyCard 内容改 flex/grid,皮肤转背景。
- [ ] 对 G01、T07、F01、R01、N01、M01 六基准做四档和同尺寸回归。
### 任务 4:通用 ModulePage
**文件:** `components/ModulePage.vue` 及其聚焦测试
- [ ] 合同禁止普通表单、列表、详情、时间轴、设置和状态卡定位。
- [ ] 装饰图转背景;字段用 grid;卡片内容自然撑高。
- [ ] 执行全部消费 ModulePage 的路由 smoke 和四档回归。
### 任务 5:模块分批迁移
- [ ] R → N → M → F:每页先合同 RED,再改文档流,再跑模块四档。
- [ ] G:保留真实弹层白名单,页面卡片、表单、Header、背景全部文档流。
- [ ] T:目录和表单使用 grid/flex;T01 世系节点和关系线用 grid/border 重做。
- [ ] A:登录、注册、重置和封存 A06 的普通内容文档流;验证弹窗/Toast 保留精确白名单。
### 任务 6:最终收敛
- [ ] `tests/document-flow-position-contract.ps1` 最终 PASS,输出零普通内容违规。
- [ ] 运行活动路由转换、关键交互 smoke、四档响应式和 `git diff --check`
- [ ] 更新验收规划与交接记录,逐一说明冻结页是否保留 `[x]`;没有截图或用户确认不得宣称视觉冻结完成。
@@ -1,53 +0,0 @@
# T01 纵向世系轴基准 Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking。
**Goal:** 将 T01 重构为纵向分代、关系线清晰、节点可选择的世系树基准页。
**Architecture:** 页面继续由 `treeState` 管理加载、树、阅读提示、空和失败状态。树态使用固定的三代模拟数据和绝对定位关系线,透明真实资产承载节点、状态卡和成员信息栏,横向滚动只服务同代分支扩展。
**Tech Stack:** uni-app、Vue 3、SCSS、现有 PNG 资产、PowerShell 静态契约、Chrome CDP 运行时验证。
## Global Constraints
- 只修改 T01 及其聚焦测试、设计文档和复制到 tree 命名空间的资产。
- 不接 API,不新增路由,不使用系统弹窗或加载。
- 不执行 Git add、commit、push、reset、checkout,不使用 worktree 或多代理。
- 不删除或覆盖现有修改、测试、文档、截图与资产。
### Task 1: 新视觉契约(RED
**Files:**
- Modify: `tests/t01-tree-state-contract.ps1`
- Modify: `tests/t01-all-states-visual-contract.ps1`
- [ ] 要求 `lineage-connector`、三代横向标签、透明节点/状态/信息栏资产和现有四条路由。
- [ ] 明确禁止 T01 继续引用三个旧不透明表面资产。
- [ ] 运行两个契约,确认因页面尚未实现新结构而失败。
### Task 2: 透明资产与纵向树(GREEN)
**Files:**
- Create: `static/assets/modules/tree/transparent/t01-member-node.png`
- Create: `static/assets/modules/tree/transparent/t01-member-node-selected.png`
- Create: `static/assets/modules/tree/transparent/t01-state-panel.png`
- Create: `static/assets/modules/tree/transparent/t01-member-drawer.png`
- Modify: `pages/tree/t01-tree-overview.vue`
- [ ] 复制经过检查的现有透明资产到 tree 模块命名空间,不改动来源文件。
- [ ] 重排三代节点,加入祖先到子代的垂直/水平关系线和明确世代带。
- [ ] 实现红色选中节点、底部透明成员栏和同代横向阅读提示。
- [ ] 保留 `AppLoading`、查询状态和 T03/T04/T06/T07 导航。
- [ ] 运行聚焦契约并修至通过。
### Task 3: 运行时与视觉复核
**Files:**
- Modify only if necessary: `tests/t01-tree-state-runtime-smoke.js`
- Create/update evidence under: `docs/design/screens/runtime/2026-07-20/t01-baseline/`
- [ ] 运行 T01 运行时 smoke,检查各状态和人物资料跳转。
- [ ] 在既有 Chrome 标签页检查四档响应式尺寸,重点排除节点裁切、断线和底栏遮挡。
- [ ] 对同一 `412×915` 状态做前后视觉对照并修复可见的 P0/P1/P2 问题。
- [ ] 只向用户展示正常树态,等待明确审批后再切下一状态;审批前不标记 `[x]`
@@ -0,0 +1,44 @@
# 全项目文档流迁移设计
## 目标
把项目中除固定顶部导航栏、固定底部导航栏、真实弹窗、Toast、遮罩、底部弹层和全屏预览外的页面结构全部改为正常文档流,消除正常文字、输入框、按钮、卡片和背景依赖 `absolute``fixed``sticky` 的布局风险。迁移覆盖 `pages/``components/`、活动页面和封存的 A06 源码,不接接口、不删除资产。
## 当前证据
只读审计发现 37 个 Vue 文件包含 `position: absolute|fixed|sticky`。用法混合了正常内容、装饰图层、页面背景、Tabbar、世系关系线和真实弹层,不能机械删除。R02 已证明普通文字、字段标签和输入框也被定位到装饰图之上,系统字体、长文案、键盘或尺寸变化时存在重叠风险。
## 单一合同
- 正常页面内容只允许块级流、flex、grid、gap、padding、margin 和内容自然撑高。
- 禁止用 `absolute``fixed``sticky`、负外边距或位移 transform 摆放普通文字、输入、按钮、卡片、页面背景或世系节点。
- `position: relative` 虽不脱离文档流,但普通内容无明确必要时不使用;不得以它作为重新叠放内容的前置手段。
- 装饰框优先使用 `background-image``border-image` 或九宫格背景;确实需要叠在明确局部容器内的纯装饰图层、角标和红点可以使用 `absolute`,但不得承担正文排版。
- 世系节点使用 grid/flex 文档流;关系线属于节点间局部绘制层,可按实际结构使用 grid 边框或受父容器约束的 `absolute`,不得用定位摆放节点正文。
- 允许定位的职责只有:固定顶部/底部导航、独立视口背景层、真实弹窗、Toast、遮罩、底部弹层、全屏预览,以及受明确父容器约束的纯装饰/角标/关系线。顶部、底部导航使用 `position: fixed`,页面内容通过正常流占位和安全区 padding 避让。每条例外必须进入精确白名单,记录文件、选择器和用途,不允许按模糊类名放行。
- 合同所有者为 `tests/document-flow-position-contract.ps1`,白名单为 `tests/document-flow-position-allowlist.json`。合同必须扫描所有 `position` 声明,包括 `relative`;普通容器不得因叠层或 z-index 习惯而残留无意义定位。运行代码、文档和其他测试不得重新定义第二套例外规则。
## 迁移顺序
1. 建立合同、精确白名单和 37 文件初始清单,测试先失败并输出全部违规位置。
2. R02 作为第一张页面样板:人物题签、档案卡、表单、状态卡全部改为背景 + flex/grid 文档流。
3. 迁移共享控件:AppButton、PageHeader、AppTabbar、ModulePageBackground、GenealogyPageBackground、GenealogyCardPageHeader 与 AppTabbar 保留用户指定的 fixed 白名单,清除其内部普通内容和装饰定位;弹层组件保留白名单定位。
4. 迁移 `ModulePage`,让普通 F/R/N/M 页面共享文档流结构。
5. 按 R → N → M → F → G → T → A 迁移专属页面;A06 源码也纳入静态合同。
6. 每批执行聚焦合同、现有交互 smoke、四档响应式、真实截图和 `git diff --check`
## 冻结页
当前冻结页不自动取消 `[x]`。每次共享组件或页面迁移后进行同尺寸前后对比:无可见变化且四档通过则保留;有任何可见变化则退回 `[~]` 等待用户复核。不得用自动化通过替代用户确认。
## 验收门槛
- 全项目合同最终为零违规,白名单只含固定顶部/底部导航和真实弹层。
- 52 条活动路由和封存 A06 源码均被扫描。
- 四档固定为 320×568、360×640、360×800、412×915。
- 长文案、约 1.3 倍字号、输入校验和键盘场景不重叠,操作区可滚动到达。
- 5173 和唯一 9222 Chrome 标签继续复用;不声明 Android 已完成。
## 边界
不执行多代理、worktree、git add/commit/push/reset/checkout;不删除、覆盖或清理现有修改、未跟踪文件、测试、文档、截图、母版和候选资产。已知仓库治理失败不通过放宽阈值或删除文件处理。
@@ -1,38 +0,0 @@
# T01 纵向世系轴基准设计
## 目标
将 T01 从“横向大画布上的独立成员卡”调整为可直接读懂亲缘关系的纵向世系轴,并作为后续世系树页面的视觉基准。本轮仅使用页面内模拟数据,不接接口。
## 已确认方向
- 始祖置于顶部中央,后代按世代向下分层。
- 同一世代横向排列;只有分支超过当前宽度时才横向拖动。
- 首屏必须完整看到始祖和主要下一代,不能以裁切节点暗示结构。
- 祖先到子代之间显示连续、清晰的关系线;选中成员以红色节点框和文字强调。
- 世代标题采用横向分层标签,不再使用分散的竖排小字。
- 选中成员信息放在底部透明信息栏中,保留“查看资料”“添加亲属”。
- 加载、阅读提示、空、失败继续由 T01 同页状态承载。
## 视觉资产
- 页面背景继续使用 T 系列 `ModulePageBackground`
- 成员节点复用现有透明卷框资产,树状态面板与底部信息栏复用现有透明金线框资产,并复制到 tree 模块命名空间。
- 移除 T01 对 `g03-create-flow-panel.png``g06-search-input-wide.png``application-status-card.png` 三个不透明表面资产的引用。
- 关系线属于数据关系可视化,可由布局元素绘制;不使用 CSS 仿造装饰图案、SVG 或表情符号。
## 状态与交互
- `loading`:保留 `AppLoading` 动画。
- `tree`:展示纵向三代世系,默认选中始祖;点击节点更新底部信息栏。
- `landscape`:说明“上下看世代、左右看同代分支”,操作后回到树态。
- `empty`:引导录入首代成员并跳转 T04。
- `error`:说明读取失败,重试后回到树态。
- 保留 T03、T04、T06、T07 路由入口。
## 响应式与验收
- 内部检查 `320×568``360×640``360×800``412×915`,不把响应式尺寸逐档交给用户确认。
- `412×915` 作为当前正常树态的首个视觉审批证据。
- H5 截图仅作内部候选;Android/HBuilderX 真机或模拟器验证仍未完成。
+26 -3
View File
@@ -3,17 +3,20 @@
> 最后更新:2026-07-20
> 用途:更换电脑、重新打开 Codex/GPT 后的唯一接管入口。
> 当前阶段:只做页面样式与视觉流程验收;不对接接口,不做功能验收。
> 当前施工停点:六个模块基准 G01、T07、F01、R01、N01、M01 已由用户明确通过;T01 已完成纵向世系轴候选,留待 2026-07-21 首项人工审核;其余页面已按所属基准完成夜间批量收敛,继续保持 `[~]` 等待人工/Android 验收。最新事实与换机提示见 `docs/夜间批量收敛交接_2026-07-20.md`。
> 当前施工停点:六个模块基准 G01、T07、F01、R01、N01、M01,以及 T01、G03、F09 和 F10 已由用户明确通过并进入当前 H5 视觉冻结。T08、F05、F06 仍按已记录的局部通过范围保持 `[~] ⚠`。F08 的正常、空相册、照片预览和相册失效四种 H5 视觉状态已由用户确认合适,但因真实入口、跳转、接口和 Android 未完成继续保持 `[~] ⚠`。F10 已成为专属视频待开放状态页,用户明确通过 412×915 候选;按钮真实返回 F01,静态合同、运行时跳转和四档响应式 smoke 通过,但 F01 真实入口、视频接口、列表/失败数据态和 Android 不在本次结论内。下一步审核 R02;其余页面保持原标记。最新事实与换机提示见 `docs/夜间批量收敛交接_2026-07-20.md`。
## 0. 2026-07-20 最新接管覆盖说明
本节覆盖下方仍保留的 2026-07-19 历史停点;不要再从 G01 正常态重新开始。
- 活动路由仍为 52 条,A02 已合并,A06 仍封存保留。
- 用户已明确通过 6 个模块基准G01、T07、F01、R01、N01、M01。`docs/验收规划.md` 应统计为 6`[x]`、46`[~]`
- T01 按用户最终选择的方案 1 完成纵向世系轴候选,明早首先审核 T01;用户明确通过前不得标 `[x]`
- 用户已明确通过 6 个模块基准、T01、G03、F09 及 F10G01、G03、T01、T07、F01、F09、F10、R01、N01、M01。`docs/验收规划.md` 应统计为 10`[x]`、42`[~]`
- T01 已完成固定左侧代际栏返工,并在唯一 Chrome 项目页完成正常、加载、阅读提示、空、失败、节点选中及四档 H5 审核;用户明确通过,现为 `[x]` 并冻结。状态联系表为 `docs/design/screens/runtime/2026-07-20/T01-state-contact-sheet.png`,响应式联系表为同目录 `T01-responsive-contact-sheet.png`
- G03 已在 320×568 完成正常、创建必填、重复家谱提醒、创建失败、录入首代和首代必填审核,并补齐其余三档正常态;用户已明确通过,现为 `[x]` 并冻结。状态联系表为 `docs/design/screens/runtime/2026-07-20/G03-sample-320x568-contact-sheet.png`,响应式联系表为同目录 `G03-responsive-contact-sheet.png`
- T08 已由用户通过 320×568 隐私、离世纪念和无权限三个直达视觉状态,联系表为 `docs/design/screens/runtime/2026-07-20/T08-direct-states-320x568-contact-sheet.png`;由于 T03 没有真实入口且其余三档未验,必须继续保持 `[~] ⚠`
- F/R/N/M 的 28 个普通任务页已统一到模块母版并删除重复编号介绍块;F02、T03—T06、T08、G03、G08—G12 已收敛旧跨模块不透明面板引用。
- 5173 服务运行,52 个页面和 2 个共享组件的 Vite 转换为 54/54;当夜 Chrome 未开放 9222,因禁止新开第二个浏览器/标签页,没有生成虚假的 CDP、截图或四档响应式通过结论。
- 后续经用户许可重启并开放 9222;复核时 5173 返回 HTTP 200,9222 仅有 1 个项目页面。该后续事实只支撑 T01 的 H5 证据,不得扩写为 Android 或其他页面已完成。
- 旧资产命名审计与历史截图数量上限仍是已知失败项;用户明确要求保留全部现有资产、截图、母版和候选,不得通过删除文件或放宽阈值处理。
- 新会话先完整阅读 `docs/夜间批量收敛交接_2026-07-20.md`,再复核当前工作区和浏览器状态。
@@ -319,6 +322,18 @@ git diff --check
GPT 不操作 Git。用户切换电脑前应自行确认以下内容已纳入版本控制并同步:
### 2026-07-20 Git 唯一换机通道
- 用户已明确:换机时**只能通过 Git 交接,不能复制整个项目目录**。因此远端仓库必须成为源码、文档、测试、实际运行资产和长期交接证据的唯一换机载体。
- 当前分支为 `main`,当前 HEAD 为 `5a31a75 Review changes batch 6 of 6`。2026-07-20 最新只读检查仍有 **56 项工作区变化**:25 项修改、4 项删除、27 项未跟踪;这些内容尚未仅凭现有 HEAD 获得换机保障。
- 在用户自行审阅、暂存、提交并成功推送这些应保留变化之前,**不得开始换机,也不得认为交接已经同步完成**。新电脑只执行 clone/pull 无法获得未提交修改、未跟踪文件和本轮删除记录。
- 用户自行提交前必须同时核对 `git status --short``git status --short --ignored``git diff --check`;不能遗漏未跟踪的规划、测试、F08 实际资产及 `.hbuilderx/launch.json`,也不能遗漏已确认的生成物/过期文档删除记录。
- `unpackage/``docs/design/screens/runtime/` 已按用户明确授权删除,不需要进入远端;`docs/design/screens/handoff/``static/assets/` 中实际引用资产、源码、有效测试、验收规划和交接记录必须进入远端。
- 本轮 GPT 仍不执行 `git add``commit``push`;这些操作必须由用户自行完成。推送后应在新电脑核对分支、HEAD 和干净/预期的 `git status`,再按本文件第 13 节停点继续。
- 2026-07-20 上传前只读审计:已配置远端 `origin`,当前上游为 `origin/main`;Git 候选文件共 589 个、约 164.88MiB,最大单文件 6,822,092 字节,**没有超过 20MiB 或 100MiB 的单文件**31 个未跟踪实体文件合计 18,674,321 字节(约 17.81MiB)。这些本地体积指标未发现常见单文件上传硬阻断,但远端网络、认证和仓库配额只有实际推送才能最终确认。
- 同次审计确认:文档流总规划/设计、F08 实际资产、定位合同与白名单、三份交接/验收文档、`.hbuilderx/launch.json` 均存在且未被 `.gitignore` 排除;长期 handoff 30 个文件全部已跟踪,没有未跟踪 handoff 文件。
- `git diff --check` 退出码为 0`git fsck --full` 退出码为 0。对象库存在历史 dangling blob/tree 和 4 个约 4.05MiB 的临时 garbage 对象,但没有对象损坏报告;不要为清理它们执行 reset、手工删除 `.git` 对象或改写历史,它们不属于本次新提交的可达内容。
- `pages.json`
- `pages/auth/a01-entry.vue`
- A02 删除记录及相关路由/测试收敛文件
@@ -361,3 +376,11 @@ GPT 不操作 Git。用户切换电脑前应自行确认以下内容已纳入版
- `tmp/` 只允许保存短期诊断图和 ImageGen 中间源,最终资产必须进入 `static/assets/`,换机代表证据必须进入 `docs/design/screens/handoff/`
- 不要执行 `git add .` 后盲目提交;用户应先用 `git status --short --ignored` 确认缓存目录仍为 `!!` 忽略状态。
- 当前不重写 Git 历史、不执行 `git gc`、不删除 `.git` 内的孤立索引;若远端仍拒绝推送,应保留完整错误信息,再按远端限制单独排查,不能用 reset 或重建仓库规避。
## 13. 2026-07-20 文档流迁移未完成阻断与清理记录
- 当前迁移**未完成**。原定位合同只扫描 `absolute/fixed/sticky`,漏掉了普通容器上不必要的 `position: relative`;F10 已被用户现场指出,其他页面也必须按同一规则重新审计。后续不得再用“剩余 112 条”作为完整数字,必须先扩展合同覆盖所有 `position` 声明。
- 正确边界:固定顶部/底部导航、真实弹层、Toast、全屏预览等保留必要定位;普通页面根容器、Header/Content 包装、正文、表单、卡片和可用 grid 同单元叠放的装饰不得残留无意义 `relative/absolute`
- 当前停点:先返工 F10,再处理 T01 世代栏与成员节点;没有新的用户视觉确认,不新增 `[x]`
- 本次用户明确授权清理无用文件。已确认 `unpackage/dist/` 为可再生成构建产物,`docs/design/screens/runtime/` 为短期诊断证据且不能作为长期唯一资料;两者可以删除。长期 `docs/design/screens/handoff/`、实际引用资产、有效测试与源码继续保留。
- 2026-07-20 已完成或被总规划取代的 F05/F06/F08/F09/F10/R02/T01 单页 plan/spec 不再作为独立合同;有效结论以本记录、`docs/验收规划.md``2026-07-20-project-document-flow-migration` 总规划/设计为准。
+22 -8
View File
@@ -4,8 +4,15 @@
- 当前阶段仍是 uni-app Android 项目的 H5 页面样式与视觉状态收敛,不对接接口。
- `pages.json` 注册 52 条活动路由;A02 已合并,A06 保持封存且源码、测试、文档和证据全部保留。
- 用户已明确通过 6 个模块基准G01、T07、F01、R01、N01、M01。
- T01 已按用户最终选择的方案 1 完成“纵向世系轴”候选:始祖居上、后代逐代向下、同代横向展开、关系线连续、节点可选择、底部显示人物信息与操作。T01 尚未经过用户视觉审核,不得标记 `[x]`
- 用户已明确通过 6 个模块基准、T01 及 G03G01、G03、T01、T07、F01、R01、N01、M01。
- T01 已在方案 1 基础上改为固定左侧代际栏,代际标签与右侧节点/关系线画布彻底分区;用户已明确通过正常、加载、阅读提示、空、失败、节点选中及四档 H5 尺寸,现标记 `[x]` 并进入视觉冻结
- G03 已完成 320×568 多状态和四档正常态审核;用户已明确通过,现标记 `[x]` 并进入视觉冻结。下一步优先审核 T 模块带 `⚠` 的 T08,但其直达视觉候选不得冒充真实入口已完成。
- T08 的 320×568 隐私、离世纪念和无权限三个直达视觉状态已由用户明确通过;T03 真实入口仍缺失且其余三档未验,所以继续保持 `[~] ⚠`。下一步优先审核 F 模块带 `⚠` 的 F05。
- F05 修正后的 320×568 失效态已由用户明确通过;F04 真实入口仍缺失、正常/加载/失败等状态尚未整体确认且其余三档未验,所以继续保持 `[~] ⚠`。下一步优先审核 F06。
- F06 已改为页面专属谱文编辑器;用户已明确通过 320×568 空表单、已有草稿、加载、就近校验、保存中、保存失败和保存成功七态。外层装饰框从整图拉伸改为九宫格后,因边缘压缩出现双线不明显;现将九宫格宽度调整为 `64rpx`,用户已明确通过双线恢复后的 320×568、360×640、360×800、412×915 四档正常态,以及边框调整后的校验/保存失败/保存成功三状态回归。F04/F05 真实入口仍缺失,Android 也未验,所以继续保持 `[~] ⚠`;下一步优先抽查 F08。
- F08 已改为专属相册详情页;用户已确认正常、空相册、照片预览和相册失效四种 H5 视觉状态合适。由于 F07 真实入口、F09 跳转、接口和 Android 未完成,继续保持 `[~] ⚠`
- F09 已改为专属照片上传页;覆盖初始、权限、已选择、上传中、失败重试和成功,单张说明区可见显示当前照片缩略图与“第 N 张 / 共 N 张”,切换照片后说明独立保存。用户在 412×915 审阅后明确要求标 `[x]`;静态合同、交互 smoke、412 布局 smoke 和四档响应式 smoke 均通过。该冻结只覆盖当前 H5 视觉候选,不包含 F08 真实入口、系统权限、上传接口、持久化或 Android;下一步审核 F10。
- F10 已改为专属视频待开放状态页;“返回家族首页”不再误入通用成功态,现真实返回 F01。用户已明确通过 412×915 候选并要求进入下一页;静态合同、真实返回运行时 smoke、四档响应式 smoke、同尺寸视觉对比和 `git diff --check` 均通过。该冻结只覆盖当前 H5 待开放视觉,不包含 F01 真实入口、视频接口、列表/失败数据态或 Android;下一步审核 R02。
- 其余未验收页面已按所属基准批量收敛,但仍保持 `[~]`;H5 自动检查不等于用户或 Android 验收。
## 2. 本轮实际修改
@@ -41,6 +48,7 @@
- `foundation-asset-audit.ps1` 仍因历史基础资产文件名含 `v1/v2/v3` 等候选式命名失败;这是既有已知问题,不能通过删除仍在使用的资产或放宽规则解决。
- `repository-handoff-size-contract.ps1``docs/design/screens` 现有 647 张历史截图超过 35 张上限失败。用户明确要求不删除或清理截图,所以保留并如实记录。
- 9222 Chrome 调试端口当夜未监听,但有 Chrome 进程连接 5173。为遵守“已有项目页时不得新开第二个浏览器或标签页”,没有另启浏览器,也没有伪造运行时 smoke、截图或四档响应式证据。
- 后续经用户明确许可重启 Chrome 并开放 9222;T01 复核期间始终复用同一项目标签页。5173 返回 HTTP 2009222 项目页面数为 1;T01 的 9 张原始状态/尺寸证据均核对为目标分辨率,两项聚焦契约通过。该后续结果不等于 Android 真机或其他候选页面已经验收。
- Android/HBuilderX 真机或模拟器验证、系统字体放大、软键盘、4GB Android 长图性能仍未完成。
## 4. 明早接管顺序
@@ -57,17 +65,19 @@
- `git status --short --ignored`
3. 保留所有现有修改、未跟踪与忽略文件;不要执行 add、commit、push、reset、checkout,不要使用 worktree 或多代理。
4. 检查 5173 和 9222。若现有 Chrome 项目页仍存在,只能复用;若需要为了 CDP 重启 Chrome,必须先让用户明确确认关闭现有窗口后再启动唯一审批窗口。
5. 首先在 412×915 展示 T01 正常树态,等待用户结论;随后审核 T01 的加载、阅读提示、空、失败、节点选择和四档响应式
6. T01 通过后,按模块抽查批量候选:G → T → F → R → N → M。优先检查带 `⚠` 的页面、表单长内容页和 320×568 小屏
5. T01 已完成 412×915 正常树态、加载、阅读提示、空、失败、节点选择和四档响应式审核,并由用户明确通过;不要重新打开返工,除非用户指出新问题或共享视觉变更触发冻结回归
6. 页面视觉审核暂时让位于全项目文档流迁移。用户指出原合同漏扫普通容器上的 `position: relative`;当前先返工 F10 的无意义定位,再处理 T01 世代栏与成员节点,之后才恢复模块抽查
7. 只有用户明确通过的页面才能标 `[x]`。没有 CDP/截图/Android 证据时不得宣称最终完成。
## 5. Git 上传前注意
- 用户换机时只能通过 Git,不能复制项目目录。换机前必须由用户自行把全部应保留修改、未跟踪文件和删除记录提交并成功推送;否则新电脑 clone/pull 后必然缺失当前工作区状态。
- 当前工作区本来就包含大量已修改和未跟踪文件,它们是用户要求保留的页面、测试、文档、截图、母版和候选资产,不属于可擅自清理的垃圾。
- 本轮没有执行任何 Git 暂存、提交、推送、重置或检出操作。
- 最后复核分支为 `main`HEAD 为 `887481a 验收完成40%``git status --short`171 项,包含用户此前和本轮需要保留的大量修改/未跟踪内容。
- 2026-07-20 最新只读复核分支为 `main`HEAD 为 `5a31a75 Review changes batch 6 of 6``git status --short`56 项,包括 25 项修改、4 项删除和 27 项未跟踪内容。
- 上传前由用户自行审阅 `git status --short``git diff --check`;不要让新的 GPT 自动删除未跟踪文件来缩小提交。
- 若要解决 647 张截图导致的仓库大小契约失败,应由用户另行决定“哪些是长期证据、哪些只留本机”,获得明确授权后再做,不得在视觉施工会话里自行删除
- `unpackage/``docs/design/screens/runtime/` 已按用户明确授权清理,不需要上传;长期 `docs/design/screens/handoff/`、实际运行资产、源码、测试和交接文档必须进入远端。既有 `repository-handoff-size-contract` 失败不得通过放宽阈值或继续删除长期资料伪造通过
- 上传前只读审计未发现常见 Git 体积硬阻断:远端 `origin` 和上游 `origin/main` 已配置,候选内容没有超过 20MiB/100MiB 的单文件,最大单文件约 6.51MiB,未跟踪文件合计约 17.81MiB;关键交接文件均未被忽略,`git diff --check``git fsck --full` 均为 0。该结论只证明本地提交候选结构可上传,网络、认证和远端配额仍必须以用户实际 push 结果为准。
## 6. 可直接发给新 GPT 的提示词
@@ -76,9 +86,13 @@
先完整阅读 AGENTS.md、docs/交接记录.md、docs/验收规划.md、docs/夜间批量收敛交接_2026-07-20.md,然后运行 git branch --show-current、git log -1 --oneline、git status --short、git status --short --ignored。
当前仍只做 uni-app Android 家谱项目的 H5 视觉审核,不接接口。G01、T07、F01、R01、N01、M01 六个模块基准已由用户明确通过。T01 已按方案1改成纵向世系轴,但尚未人工通过;其余页面已按模块基准批量收敛,仍是待验收候选。5173 当夜运行正常,52 页面 + 2 共享组件 Vite 转换为 54/54;9222 当夜未监听,因此不能把运行时 smoke、四档响应式或 Android 写成已完成。
当前仍 uni-app Android 家谱项目的 H5 视觉阶段,不接接口。用户已明确通过 G01、G03、T01、T07、F01、F09、F10、R01、N01、M01 等当前 H5 视觉候选;其余页面以 `docs/验收规划.md` 为准,不能根据本提示词自行新增 `[x]`。Android/HBuilderX、接口和业务功能仍未完成。
先检查 5173 和 9222。如果已有 Chrome 项目页,只能复用同一个窗口和标签页;若必须重启以开放 9222,先征得我的明确同意。先在 412×915 展示 T01 正常树态,之后按加载、阅读提示、空、失败、节点选中和四档尺寸审核。只有我明确说“通过”才能标 [x]。发现问题只返工当前页。T01 通过后按 G、T、F、R、N、M 模块抽查批量候选,优先带 ⚠ 页面和 320×568 小屏
当前最高优先级不是继续逐页视觉审核,而是完成全项目文档流迁移。原定位合同只扫描 `absolute/fixed/sticky`,漏掉普通容器上无意义的 `position: relative`,所以此前“剩余 112 条”不能作为完整数字。先扩展合同覆盖所有 `position`,再返工 F10,随后处理 T01 世代栏与成员节点。顶部和底部导航应使用必要的 `fixed`;普通根容器、Header/Content 包装、文本、表单和卡片不得滥用定位;真实弹窗、Toast、全屏预览和确有必要的局部装饰层可以保留精确白名单
换机只能通过 Git。开始工作前先确认用户已把上一台电脑的全部应保留修改、未跟踪文件和删除记录提交并推送;然后核对当前分支、HEAD、`git status --short` 和 `git status --short --ignored`。若远端没有上述 2026-07-20 文档流迁移文件、测试、F08 资产或交接更新,立即停止,不能用旧 HEAD 猜状态。
检查 5173 和 9222。如果已有 Chrome 项目页,只能复用同一个窗口和标签页;若必须重启以开放 9222,先征得我的明确同意。只有用户明确说“通过”才能新增 `[x]`;定位迁移造成可见变化时,冻结页面必须重新对比,不能用自动测试代替用户确认。
请先简要复述当前停点,再开始检查;没有真实证据不得宣称完成。已知 foundation-asset-audit 和 repository-handoff-size-contract 的失败不能靠放宽阈值或删除用户文件处理。
```
+17 -9
View File
@@ -17,12 +17,20 @@
### 2026-07-20 夜间批量收敛说明
- 用户已明确授权:T01 留待次日人工审核,其余未验收页面不再逐页提问,统一按 G01、T07、F01、R01、N01、M01 六个已确认基准推进。
- 用户已明确授权:其余未验收页面不再逐页提问,统一按 G01、T07、F01、R01、N01、M01 六个已确认基准推进T01 已在后续人工审核中明确通过
- 本轮已将 F/R/N/M 的 28 个普通任务页统一到各自模块母版,删除重复的页面编号介绍块;表单、列表、详情、时间轴、设置和状态页仍保留不同业务结构。
- F02、T03—T06、T08、G03、G08—G12 已移除跨模块旧不透明业务面板引用,改用所属模块或 G01/T07 基准的真实透明资产。
- T01 已形成纵向世系轴候选:始祖在上、后代向下分代、同代横向扩展、关系线连续、节点可选择、底部人物信息栏独立。
- 52 条活动路由及 2 个共享组件已通过 5173 Vite 逐文件转换(54/54);68 项 PowerShell 静态测试中 66 项通过。剩余 2 项为已知仓库治理项:旧基础资产命名审计和历史截图数量上限。
- 当前 Chrome 项目页仍连接 5173,但没有开放 9222 调试端口;为避免违反“不得新开第二个浏览器或第二个项目标签页”,本轮未伪造 CDP 运行截图、四档响应式和交互 smoke 证据。除用户已明确通过的基准页外,其余页面继续保持 `[~]`,明早人工复核后再决定是否标 `[x]`
- 后续复核已在复用的唯一 Chrome 项目页完成:用户先通过 T01 的 412×915 正常树态,再明确通过加载、阅读提示、空、失败、节点选中及 320×568、360×640、360×800、412×915 四档尺寸。状态与响应式联系表分别为 `docs/design/screens/runtime/2026-07-20/T01-state-contact-sheet.png``T01-responsive-contact-sheet.png`;T01 自本次确认起进入视觉冻结。该结论仍不包含 Android 真机、接口或业务功能验收。
- G 模块后续抽查从 320×568 长表单风险页 G03 开始;用户已明确通过正常、必填校验、重复家谱提醒、创建失败、录入首代和首代必填校验,随后补齐并检查其余三档正常态。状态联系表为 `docs/design/screens/runtime/2026-07-20/G03-sample-320x568-contact-sheet.png`,响应式联系表为同目录 `G03-responsive-contact-sheet.png`;G03 自本次确认起进入视觉冻结。该结论不包含真实创建接口、持久化、Android 真机或中断恢复功能验收。
- T 模块优先抽查带 `⚠` 的 T08;用户已明确通过其 320×568 隐私、离世纪念和无权限三个直达视觉状态,联系表为 `docs/design/screens/runtime/2026-07-20/T08-direct-states-320x568-contact-sheet.png`。由于 T03 仍无真实入口且其余三档未验,T08 继续保持 `[~] ⚠`;该视觉子项通过不得冒充入口、流程或整页验收完成。
- F 模块带 `⚠` 页面从 F05 开始抽查;用户已明确通过修正后的 320×568 失效态,失效内容使用“谱文已失效/这篇谱文已无法查看/返回谱文列表”的页面专属语义。修复前后联系表为 `docs/design/screens/runtime/2026-07-20/F05-expired-before-after-320x568.png`。由于 F04 真实入口仍缺失、正常/加载/失败等状态尚未整体确认且其余三档未验,F05 继续保持 `[~] ⚠`;下一步审核 F06。
- F06 已改为页面专属谱文编辑器;用户已明确通过 320×568 的空表单、已有草稿、加载、就近校验、保存中、保存失败和保存成功七态,联系表为 `docs/design/screens/runtime/2026-07-20/F06-seven-states-320x568-contact-sheet.png`。外层装饰框曾因整图纵向拉伸导致边线变形,改为九宫格后又因边缘压缩只剩一根明显线条;现将九宫格宽度调整为 `64rpx`,用户已明确通过双线恢复后的 320×568、360×640、360×800、412×915 四档正常态,标准表面截图联系表为同目录 `F06-double-line-64-surface-responsive-contact-sheet.png`;边框调整后的校验/保存失败/保存成功三状态回归也已由用户明确通过,联系表为 `F06-double-line-64-surface-state-contact-sheet.png`。F04F05 真实入口仍缺失,Android 也未验证,因此继续保持 `[~] ⚠`
- F08 已改为专属相册详情页;用户已查看并确认正常、空相册、照片预览和相册失效四种 H5 视觉状态合适。320×568 状态证据为 `docs/design/screens/runtime/2026-07-20/F08-states-320x568-v2-contact-sheet.png`,412×915 逐态证据由同一 Chrome 项目页完成。由于 F07 真实入口、F09 跳转、接口和 Android 未完成,F08 继续保持 `[~] ⚠`
- F09 已改为专属照片上传页,覆盖初始、权限提示、已选择、上传中、失败重试和上传成功;单张说明区补充当前照片缩略图、“第 N 张 / 共 N 张”和切换后独立保存的真实本地绑定。用户在 412×915 审阅后明确要求标记 `[x]`;聚焦静态合同、交互 smoke、412 布局 smoke 和四档响应式 smoke 均通过。该 `[x]` 只表示当前 H5 视觉候选冻结,不包含 F08 真实入口、系统权限、上传接口、持久化或 Android 验证。
- F10 已改为专属视频待开放状态页,移除点击“返回家族首页”后误入通用成功态的问题;按钮现真实返回 F01。用户已明确通过 412×915 候选并要求进入下一页;静态合同、真实返回运行时 smoke、四档响应式 smoke、同尺寸前后视觉对比和 `git diff --check` 均通过。该 `[x]` 只冻结当前 H5 待开放视觉,不表示视频接口、列表/失败数据态、F01 真实入口或 Android 已完成。
页面只有同时满足以下条件才能从 `[~]` 改为 `[x]`
@@ -199,7 +207,7 @@ G01、G03、G05、G06、G08、G09、G10、G11、G12 共 9 个活动 G 页面统
| 状态 | 序号 | 页面 | 真实入口与必须覆盖的可能性 |
| --- | ---: | --- | --- |
| `[x]` | 4 | G01 我的家谱 | 用户已在本轮逐项通过正常、空、加载、失败、添加家谱弹层、切换家谱弹层、独立滚动及响应式检查;作为 G 系列基准冻结。Android/HBuilderX 与 4GB Android 长图性能仍待后续平台验证,不等于接口或 Android 功能通过 |
| `[~]` | 5 | G03 创建家谱 | G01 空态或“添加家谱”→创建独立家谱;姓氏、谱名、堂号、所在地、访问规则,创建前提示搜索潜在已有家谱;覆盖必填校验、创建中、失败和已创建但始祖未完成的中断恢复;成功后直接成为创建者→录入始祖→进入 G05 总览,不直接跳 T01;下属支系创建待接口提供上级谱与权限字段 |
| `[x]` | 5 | G03 创建家谱 | 用户已明确通过创建信息、必填校验、重复家谱提醒、创建失败、录入首代、首代必填校验及四档 H5 尺寸;320×568 下主操作、错误文案和弹窗无溢出或遮挡,三项聚焦契约与运行时 smoke 通过。真实创建、持久化、中断恢复、下属支系字段、Android 真机与接口功能仍待后续验证 |
| `[~]` | 6 | G06 加入家谱 | G01 空态或“添加家谱”→加入;搜索/邀请码双模式、地区与谱名筛选;结果卡展示谱名、姓氏、地区、堂号、所属上级谱、当前支系、管理/认证信息、成员规模和更新时间,并覆盖未加入、已加入、审核中、被拒绝、已退出/移除、我创建的六种操作状态;邀请码验证、初始、加载、结果、无结果、无效/过期邀请码、失败、清空输入 |
| `[~]` | 7 | G08 关系确认与入谱 | G06 选定家谱→填写真实姓名和以已知长辈为参照的文字关系;搜索态显示申请说明/提交申请并在成功后进入 G09,邀请码态显示可选补充信息/确认加入并明确无需审核,成功后返回 G01 选中新家谱;覆盖校验、重复提交、提交中、成功/失败、返回保护;结构化关系待接口提供后迁移 |
| `[~]` | 8 | G09 我的申请 | G08 搜索来源提交成功→申请记录;待审、通过、拒绝、修改后重新提交、撤回、空、失败;邀请码来源不进入本页 |
@@ -217,13 +225,13 @@ G01、G03、G05、G06、G08、G09、G10、G11、G12 共 9 个活动 G 页面统
| 状态 | 序号 | 页面 | 真实入口与必须覆盖的可能性 |
| --- | ---: | --- | --- |
| `[~]` | 13 | T01 世系树 | 已按方案 1 重构为纵向世系轴候选:始祖居上、后代向下分代、同代横向扩展、关系线连续、节点选中与底部人物信息栏;加载、、空、失败、阅读提示均保留。52 路由 Vite 转换和聚焦静态契约通过,等待 2026-07-21 用户视觉审核;9222 缺失导致当夜未完成 CDP 四档和交互 smoke |
| `[x]` | 13 | T01 世系树 | 用户已明确通过固定左侧代际栏方案:代际标签与关系线分区,始祖居上、后代向下分代、同代横向扩展,节点可选并显示底部人物信息栏;加载、阅读提示、空、失败、节点选中和四档 H5 尺寸均有真实证据。固定栏横向拖动前后保持不动,聚焦静态契约通过;Android 真机、接口与业务功能仍待后续验证 |
| `[~]` | 14 | T04 新增亲属 | T01→新增;首位成员/普通亲属、关系选择、必填、保存中、结果 |
| `[x]` | 15 | T07 成员目录 | T01→成员目录;列表、搜索、无结果、失败、成员选择 |
| `[~]` | 16 | T03 成员档案 | T01/T07→成员;正常、失败、隐私字段、编辑入口 |
| `[~]` | 17 | T05 编辑成员 | T03→编辑;字段校验、保存中、成功/失败、无权限 |
| `[~]` | 18 | T06 编辑关系 | T01→编辑关系;冲突、循环关系、说明弹窗、保存失败 |
| `[~]` ⚠ | 19 | T08 成员状态 | 应由 T03 进入;去世、隐私隐藏、无权限;当前无真实入口 |
| `[~]` ⚠ | 19 | T08 成员状态 | 用户已明确通过 320×568 隐私隐藏、离世纪念和无权限三个直达视觉状态,聚焦契约与运行时 smoke 通过;应由 T03 进入,但当前无真实入口且其余三档未验,因此只记录视觉子项通过,不标 `[x]` |
### 5.5 家族动态与内容
@@ -233,12 +241,12 @@ G01、G03、G05、G06、G08、G09、G10、G11、G12 共 9 个活动 G 页面统
| `[~]` | 21 | F02 发布动态 | F01→发布;空内容、键盘、提交中、成功/失败、重复提交 |
| `[~]` | 22 | F03 动态详情 | F01 动态卡→详情;正文、评论、操作结果、内容失效 |
| `[~]` | 23 | F04 谱文列表 | F01→谱文;分类、列表、空、失败、新建入口 |
| `[~]` ⚠ | 24 | F05 谱文详情 | 应由 F04 文章进入;详情、收藏、失效内容;当前卡片只弹提示 |
| `[~]` ⚠ | 25 | F06 编辑谱文 | 应由 F04 新建/F05 编辑进入;草稿、校验、保存、失败 |
| `[~]` ⚠ | 24 | F05 谱文详情 | 用户已明确通过修正后的 320×568 失效态;应由 F04 文章进入,但当前无真实入口,正常/加载/失败等状态尚未整体确认且其余三档未验,因此只记录失效视觉子项通过,不标 `[x]` |
| `[~]` ⚠ | 25 | F06 编辑谱文 | 用户已明确通过 320×568 七态、双线九宫格修正后的四档正常态,以及边框调整后的校验/保存失败/保存成功三状态回归;应由 F04 新建/F05 编辑进入,但当前无真实入口,Android 也未验证,因此不标 `[x]` |
| `[~]` | 26 | F07 相册列表 | F01→相册;列表、空、失败、新建相册 |
| `[~]` ⚠ | 27 | F08 相册详情 | 应由 F07 相册进入;照片墙、空相册、预览、失效;当前无跳转 |
| `[~]` | 28 | F09 上传照片 | 应由 F08→添加照片;权限、选择、上传进度、失败重试 |
| `[~]` | 29 | F10 家族视频 | 应由 F01 内容入口进入;未开放、空、列表失败;当前无入口 |
| `[~]` ⚠ | 27 | F08 相册详情 | 用户已确认正常、空相册、照片预览和相册失效四种 H5 视觉状态合适;F07 真实入口、F09 跳转、接口和 Android 未完成,因此不标 `[x]` |
| `[x]` | 28 | F09 上传照片 | 用户已明确要求当前 H5 视觉候选标 `[x]`:专属相册续页式上传页覆盖初始、权限、选择、上传、失败重试和成功,单张说明与当前照片有可见绑定,聚焦合同、交互、412 布局及四档响应式 smoke 通过;F08 真实入口、系统权限、上传接口、持久化和 Android 仍属后续功能验证 |
| `[x]` | 29 | F10 家族视频 | 用户已明确通过当前 H5 待开放状态页:412×915 视觉保持原候选,按钮真实返回 F01,聚焦合同、运行时跳转和四档响应式 smoke 通过;F01 真实入口、视频接口、列表失败数据态和 Android 仍待后续验证 |
### 5.6 人物、礼仪与档案
+11 -4
View File
@@ -192,6 +192,7 @@ const openSection = (key) => {
inset: 0;
width: 100%;
height: 100%;
pointer-events: none;
}
.feed-shortcut {
position: relative;
@@ -212,10 +213,14 @@ const openSection = (key) => {
.feed-card,
.feed-state-card {
position: relative;
display: flex;
width: 100%;
height: 230rpx;
min-height: 98px;
flex-direction: column;
justify-content: center;
margin-top: 17rpx;
box-sizing: border-box;
}
.feed-card__skin,
.feed-state-card > image,
@@ -224,11 +229,12 @@ const openSection = (key) => {
inset: 0;
width: 100%;
height: 100%;
pointer-events: none;
}
.feed-card__copy {
position: absolute;
inset: 15% 9%;
position: relative;
z-index: 1;
padding: 15% 9%;
}
.feed-card text {
display: block;
@@ -260,9 +266,10 @@ const openSection = (key) => {
margin-top: 70rpx;
}
.feed-state-card > view {
position: absolute;
inset: 23% 10%;
position: relative;
z-index: 1;
padding: 23% 10%;
box-sizing: border-box;
text-align: center;
}
.feed-state-card text {
+14 -8
View File
@@ -99,19 +99,22 @@ onUnmounted(() => {
}
.publish-panel {
width: calc(100% - 32rpx);
height: min(640px, calc((100vw - 16px) * 1.48));
min-height: min(640px, calc((100vw - 16px) * 1.48));
margin: 18rpx auto 0;
padding: 9%;
box-sizing: border-box;
}
.publish-panel__skin {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
pointer-events: none;
}
.publish-form,
.publish-result {
position: absolute;
inset: 9% 9%;
position: relative;
z-index: 1;
}
.publish-form > text,
.publish-result > text {
@@ -133,20 +136,23 @@ onUnmounted(() => {
}
.publish-field {
position: relative;
height: 250rpx;
display: flex;
min-height: 250rpx;
margin-top: 24rpx;
padding: 25rpx;
box-sizing: border-box;
}
.publish-field image {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
pointer-events: none;
}
.publish-field textarea {
position: absolute;
inset: 25rpx;
position: relative;
z-index: 1;
width: auto;
width: 100%;
height: 200rpx;
color: $ink;
font-size: 24rpx;
@@ -159,7 +165,7 @@ onUnmounted(() => {
margin-top: 24rpx;
}
.publish-result {
top: 29%;
margin-top: 20%;
text-align: center;
}
.publish-result > .app-button {
+383 -2
View File
@@ -1,5 +1,386 @@
<!-- 页面编号F-06用途新建与编辑谱文 -->
<template><ModulePage page-id="f06" /></template>
<template>
<view
class="article-editor-page"
:class="`article-editor-state--${editorState}`"
>
<ModulePageBackground module="family" />
<view class="article-editor-page__header">
<PageHeader title="编辑谱文" />
</view>
<view v-if="editorState === 'loading'" class="article-editor-loading">
<AppLoading
text="正在打开谱文编辑器"
description="请稍候,草稿正在展开。"
/>
</view>
<view
v-else-if="editorState === 'success'"
class="article-editor-content"
>
<view class="editor-result-card">
<view class="editor-result-card__body">
<text class="editor-eyebrow">保存结果</text>
<text class="editor-result-card__title">谱文已保存</text>
<text class="editor-result-card__copy"
>这篇谱文已收录可返回谱文列表继续查看</text
>
<AppButton block label="返回谱文列表" @click="returnToList" />
</view>
</view>
</view>
<view v-else class="article-editor-content">
<view class="editor-panel">
<view class="editor-panel__body">
<text class="editor-eyebrow">草稿 · 未发布</text>
<text class="editor-title">把值得传承的故事写下来</text>
<text class="editor-intro"
>补充标题分类和正文保存后可返回谱文列表继续查看</text
>
<view class="editor-field">
<text class="editor-field__label">文章标题</text>
<view
class="editor-control"
:class="{ 'editor-control--error': fieldErrors.title }"
>
<image :src="fieldFrame" mode="scaleToFill" />
<input
v-model="form.title"
maxlength="40"
placeholder="请输入文章标题"
placeholder-class="editor-placeholder"
@input="clearFieldError('title')"
/>
</view>
<text v-if="fieldErrors.title" class="editor-field__error">{{
fieldErrors.title
}}</text>
</view>
<view class="editor-field">
<text class="editor-field__label">文章分类</text>
<view
class="editor-control"
:class="{ 'editor-control--error': fieldErrors.category }"
>
<image :src="fieldFrame" mode="scaleToFill" />
<input
v-model="form.category"
maxlength="20"
placeholder="如:家风家训"
placeholder-class="editor-placeholder"
@input="clearFieldError('category')"
/>
</view>
<text v-if="fieldErrors.category" class="editor-field__error">{{
fieldErrors.category
}}</text>
</view>
<view class="editor-field">
<text class="editor-field__label">正文内容</text>
<view
class="editor-control editor-control--textarea"
:class="{ 'editor-control--error': fieldErrors.content }"
>
<image :src="fieldFrame" mode="scaleToFill" />
<textarea
v-model="form.content"
maxlength="1200"
placeholder="记录家训、往事或想留给后人的话"
placeholder-class="editor-placeholder"
@input="clearFieldError('content')"
/>
</view>
<text v-if="fieldErrors.content" class="editor-field__error">{{
fieldErrors.content
}}</text>
</view>
<text class="editor-draft-note"
>当前为未发布草稿真实草稿保存将在接口阶段接入</text
>
<text v-if="editorState === 'error'" class="editor-save-error"
>保存失败已填写内容仍保留请稍后重试</text
>
<AppButton
block
:label="actionLabel"
:disabled="editorState === 'saving'"
@click="submit"
/>
</view>
</view>
</view>
</view>
</template>
<script setup>
import ModulePage from "@/components/ModulePage.vue";
import { computed, reactive, ref } from "vue";
import { onLoad, onUnload } from "@dcloudio/uni-app";
import AppButton from "@/components/AppButton.vue";
import AppLoading from "@/components/AppLoading.vue";
import ModulePageBackground from "@/components/ModulePageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
const fieldFrame =
"/static/assets/modules/family/transparent/module-field-frame.png";
const draftFixture = {
title: "汤氏家训辑录",
category: "家风家训",
content:
"孝友传家,勤俭立业;敬祖睦宗,诚实待人。愿后人常怀感恩,彼此扶持。",
};
const editorState = ref("form");
const allowedStates = new Set([
"draft",
"loading",
"validation",
"saving",
"error",
"success",
]);
const form = reactive({ title: "", category: "", content: "" });
const fieldErrors = reactive({ title: "", category: "", content: "" });
let saveTimer = null;
const actionLabel = computed(() =>
editorState.value === "saving"
? "正在保存…"
: editorState.value === "error"
? "重新保存"
: "保存谱文",
);
const fillDraft = (failed = false) => {
Object.assign(form, draftFixture, failed ? { title: "保存失败" } : {});
};
const showAllFieldErrors = () => {
fieldErrors.title = "请填写文章标题";
fieldErrors.category = "请填写文章分类";
fieldErrors.content = "请填写正文内容";
};
onLoad((query) => {
const requestedState = allowedStates.has(query.state) ? query.state : "form";
if (["draft", "saving", "error"].includes(requestedState)) {
fillDraft(requestedState === "error");
}
if (requestedState === "validation") showAllFieldErrors();
editorState.value = requestedState;
});
const clearFieldError = (field) => {
fieldErrors[field] = "";
if (
editorState.value === "error" ||
editorState.value === "validation"
) {
editorState.value = "form";
}
};
const validate = () => {
fieldErrors.title = form.title.trim() ? "" : "请填写文章标题";
fieldErrors.category = form.category.trim() ? "" : "请填写文章分类";
fieldErrors.content = form.content.trim() ? "" : "请填写正文内容";
return !fieldErrors.title && !fieldErrors.category && !fieldErrors.content;
};
const submit = () => {
if (editorState.value === "saving") return;
if (!validate()) {
editorState.value = "validation";
return;
}
editorState.value = "saving";
saveTimer = setTimeout(() => {
editorState.value =
form.title.trim() === "保存失败" ? "error" : "success";
saveTimer = null;
}, 320);
};
const returnToList = () => uni.navigateBack();
onUnload(() => {
if (saveTimer) clearTimeout(saveTimer);
});
</script>
<style scoped lang="scss">
.article-editor-page {
position: relative;
min-height: 100vh;
overflow-x: hidden;
background: $paper;
}
.article-editor-page__header,
.article-editor-loading,
.article-editor-content {
position: relative;
z-index: 2;
}
.article-editor-loading {
padding-top: 150rpx;
}
.article-editor-content {
padding: 18rpx 20rpx calc(38rpx + env(safe-area-inset-bottom));
}
.editor-panel,
.editor-result-card {
position: relative;
width: 100%;
border: 20rpx solid transparent;
border-image-source: url("/static/assets/modules/family/transparent/module-content-frame.png");
border-image-slice: 72 fill;
border-image-width: 64rpx;
border-image-repeat: stretch;
box-sizing: border-box;
}
.editor-panel__body {
position: relative;
z-index: 1;
padding: 38rpx 34rpx 42rpx;
}
.editor-eyebrow {
display: block;
color: $brand-red;
font-size: 23rpx;
letter-spacing: 3rpx;
text-align: center;
}
.editor-title {
display: block;
margin-top: 10rpx;
color: $ink;
font-family: STKaiti, KaiTi, serif;
font-size: 34rpx;
font-weight: 700;
text-align: center;
}
.editor-intro {
display: block;
margin: 10rpx 8rpx 22rpx;
color: $ink-muted;
font-size: 23rpx;
line-height: 1.55;
text-align: center;
}
.editor-field {
margin-top: 18rpx;
}
.editor-field__label {
display: block;
margin: 0 8rpx 8rpx;
color: $ink;
font-size: 25rpx;
font-weight: 700;
}
.editor-control {
position: relative;
display: flex;
height: 82rpx;
align-items: center;
}
.editor-control > image {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
pointer-events: none;
}
.editor-control input,
.editor-control textarea {
position: relative;
z-index: 1;
box-sizing: border-box;
width: 100%;
color: $ink;
font-size: 24rpx;
}
.editor-control input {
width: 100%;
height: 82rpx;
padding: 0 28rpx;
}
.editor-control--textarea {
height: 220rpx;
padding: 20rpx 24rpx;
box-sizing: border-box;
}
.editor-control textarea {
width: 100%;
height: 180rpx;
line-height: 1.65;
}
.editor-control--error {
filter: sepia(0.18) saturate(1.2);
}
.editor-placeholder {
color: #9e8e79;
}
.editor-field__error,
.editor-save-error {
display: block;
color: $brand-red;
font-size: 24rpx;
line-height: 34rpx;
}
.editor-field__error {
margin: 5rpx 8rpx 0;
}
.editor-draft-note {
display: block;
margin: 20rpx 10rpx 0;
color: $ink-muted;
font-size: 22rpx;
line-height: 1.55;
text-align: center;
}
.editor-save-error {
margin: 12rpx 8rpx 0;
text-align: center;
}
.editor-panel__body > .app-button {
margin-top: 22rpx;
}
.editor-result-card {
min-height: 420rpx;
}
.editor-result-card__body {
position: relative;
z-index: 1;
padding: 112rpx 52rpx 68rpx;
text-align: center;
}
.editor-result-card__title {
display: block;
margin-top: 14rpx;
color: $ink;
font-family: STKaiti, KaiTi, serif;
font-size: 36rpx;
font-weight: 700;
}
.editor-result-card__copy {
display: block;
margin-top: 16rpx;
color: $ink-muted;
font-size: 24rpx;
line-height: 1.65;
}
.editor-result-card__body > .app-button {
margin-top: 30rpx;
}
@media (max-width: 340px) {
.article-editor-content {
padding-right: 14rpx;
padding-left: 14rpx;
}
.editor-panel__body {
padding-right: 26rpx;
padding-left: 26rpx;
}
}
</style>
+339 -2
View File
@@ -1,5 +1,342 @@
<!-- 页面编号F-08用途相册详情与照片墙 -->
<template><ModulePage page-id="f08" /></template>
<template>
<view class="album-detail-page" :class="`album-state--${albumState}`">
<ModulePageBackground module="family" />
<view class="album-detail-header"><PageHeader title="相册详情" /></view>
<view class="album-detail-content">
<view v-if="albumState === 'expired'" class="album-expired-state">
<view class="album-state-card__body">
<text class="album-state-card__eyebrow">相册状态</text>
<text class="album-state-card__title">相册已失效</text>
<text class="album-state-card__copy">这个相册已无法查看</text>
<AppButton block label="返回相册列表" @click="returnToAlbums" />
</view>
</view>
<template v-else>
<view class="album-heading">
<text class="album-heading__eyebrow">家族影像 · 2024</text>
<text class="album-heading__title">2024 春节团圆</text>
<text class="album-heading__copy"
>一家人围坐团圆让今朝欢聚与祖居旧影留在同一本相册里</text
>
<text class="album-heading__count"
>{{ albumState === "empty" ? "0 张照片" : "5 张照片" }}</text
>
</view>
<view v-if="albumState === 'empty'" class="album-empty-state">
<view class="album-state-card__body">
<text class="album-state-card__eyebrow">照片墙</text>
<text class="album-state-card__title">相册里还没有照片</text>
<text class="album-state-card__copy"
>添加第一张团圆影像把值得珍藏的时刻留在这里</text
>
</view>
</view>
<view v-else class="album-wall">
<view
class="album-photo-tile album-photo-tile--hero"
@click="openPreview(0)"
>
<image
class="album-hero-photo"
:src="photos[0].src"
:alt="photos[0].alt"
mode="aspectFill"
/>
<text class="album-photo-tile__caption">{{ photos[0].caption }}</text>
</view>
<view class="album-photo-grid">
<view
v-for="(photo, index) in photos.slice(1)"
:key="photo.src"
class="album-photo-tile album-photo-tile--grid"
@click="openPreview(index + 1)"
>
<image
class="album-photo-tile__image"
:src="photo.src"
:alt="photo.alt"
mode="aspectFill"
/>
<text class="album-photo-tile__caption">{{ photo.caption }}</text>
</view>
</view>
</view>
<view class="album-upload-action" @click="showUploadNotice">
<AppButton block label="添加照片" />
</view>
</template>
</view>
<view v-if="previewVisible" class="album-preview">
<view class="album-preview__toolbar">
<text class="album-preview__title">照片预览</text>
<view class="album-preview__close" @click="closePreview"><text>关闭</text></view>
</view>
<image
class="album-preview__image"
:src="photos[previewIndex].src"
:alt="photos[previewIndex].alt"
mode="aspectFit"
/>
<view class="album-preview__footer">
<text class="album-preview__position"
>{{ previewIndex + 1 }} / {{ photos.length }}</text
>
<text class="album-preview__caption">{{ photos[previewIndex].caption }}</text>
</view>
</view>
<AppToast :visible="uploadNoticeVisible" :message="uploadNoticeMessage" />
</view>
</template>
<script setup>
import ModulePage from "@/components/ModulePage.vue";
import { ref } from "vue";
import { onBackPress, onLoad, onUnload } from "@dcloudio/uni-app";
import AppButton from "@/components/AppButton.vue";
import AppToast from "@/components/AppToast.vue";
import ModulePageBackground from "@/components/ModulePageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
const albumState = ref("normal");
const previewVisible = ref(false);
const previewIndex = ref(0);
const uploadNoticeVisible = ref(false);
const uploadNoticeMessage = "上传照片将在入口接通后开放";
let uploadNoticeTimer;
const photos = [
{ src: "/static/assets/modules/family/f08/f08-reunion-hero.png", alt: "春节团圆时三代家人的合影", caption: "除夕团圆 · 2024" },
{ src: "/static/assets/modules/family/f08/f08-family-portrait.png", alt: "家人在院落前的春节合影", caption: "院前合影 · 2024" },
{ src: "/static/assets/modules/family/f08/f08-reunion-table.png", alt: "家人围坐吃年夜饭", caption: "围桌守岁 · 2024" },
{ src: "/static/assets/modules/family/f08/f08-ancestral-home.png", alt: "祖居院落的复古旧照", caption: "祖居旧影 · 1968" },
{ src: "/static/assets/modules/family/f08/f08-ancestral-portrait.png", alt: "老一辈家人在祖居门前的合影", caption: "门前合影 · 1972" },
];
const openPreview = (index) => {
previewIndex.value = index;
previewVisible.value = true;
albumState.value = "preview";
};
const closePreview = () => {
previewVisible.value = false;
albumState.value = "normal";
};
const showUploadNotice = () => {
uploadNoticeVisible.value = true;
clearTimeout(uploadNoticeTimer);
uploadNoticeTimer = setTimeout(() => {
uploadNoticeVisible.value = false;
}, 1800);
};
const returnToAlbums = () => {
uni.redirectTo({ url: "/pages/family/f07-album-list" });
};
onLoad((query) => {
albumState.value =
query.state === "empty"
? "empty"
: query.state === "preview"
? "preview"
: query.state === "expired"
? "expired"
: "normal";
previewVisible.value = albumState.value === "preview";
});
onBackPress(() => {
if (!previewVisible.value) return false;
closePreview();
return true;
});
onUnload(() => {
clearTimeout(uploadNoticeTimer);
});
</script>
<style scoped lang="scss">
.album-detail-page {
position: relative;
min-height: 100vh;
overflow-x: hidden;
background: $paper;
}
.album-detail-header,
.album-detail-content {
position: relative;
z-index: 2;
}
.album-detail-content {
padding: 22rpx 24rpx calc(48rpx + env(safe-area-inset-bottom));
}
.album-heading {
padding: 18rpx 12rpx 20rpx;
text-align: center;
}
.album-heading text,
.album-state-card__body text,
.album-preview__footer text {
display: block;
}
.album-heading__eyebrow,
.album-state-card__eyebrow {
color: $brand-red;
font-size: 22rpx;
letter-spacing: 2rpx;
}
.album-heading__title,
.album-state-card__title {
margin-top: 8rpx;
color: $ink;
font-family: STKaiti, KaiTi, serif;
font-size: 36rpx;
font-weight: 700;
}
.album-heading__copy,
.album-state-card__copy {
margin-top: 10rpx;
color: $ink-muted;
font-size: 23rpx;
line-height: 1.6;
}
.album-heading__count {
margin-top: 12rpx;
color: #806a51;
font-size: 22rpx;
}
.album-wall {
margin-top: 8rpx;
}
.album-photo-tile {
position: relative;
min-height: 44px;
overflow: hidden;
box-sizing: border-box;
border: 2rpx solid rgba(179, 133, 63, 0.62);
background: rgba(245, 238, 226, 0.86);
box-shadow: 0 8rpx 18rpx rgba(91, 60, 32, 0.12);
}
.album-photo-tile--hero {
width: 100%;
aspect-ratio: 16 / 9;
}
.album-photo-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14rpx;
margin-top: 14rpx;
}
.album-photo-tile--grid {
aspect-ratio: 4 / 3;
}
.album-hero-photo,
.album-photo-tile__image {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
}
.album-photo-tile__caption {
position: absolute;
z-index: 1;
right: 0;
bottom: 0;
left: 0;
padding: 10rpx 12rpx;
background: rgba(48, 35, 25, 0.72);
color: #fffaf0;
font-size: 21rpx;
line-height: 1.35;
}
.album-upload-action {
margin-top: 24rpx;
}
.album-empty-state,
.album-expired-state {
position: relative;
width: 100%;
min-height: 430rpx;
box-sizing: border-box;
border: 20rpx solid transparent;
border-image-source: url("/static/assets/modules/family/transparent/module-content-frame.png");
border-image-slice: 72 fill;
border-image-width: 64rpx;
border-image-repeat: stretch;
}
.album-state-card__body {
padding: 104rpx 40rpx 62rpx;
text-align: center;
}
.album-state-card__body > .app-button {
margin-top: 34rpx;
}
.album-preview {
position: fixed;
z-index: 80;
inset: 0;
display: flex;
flex-direction: column;
background: rgba(24, 17, 13, 0.96);
}
.album-preview__toolbar {
display: flex;
min-height: 96rpx;
align-items: center;
justify-content: space-between;
padding: calc(env(safe-area-inset-top) + 12rpx) 24rpx 12rpx;
}
.album-preview__title {
color: #fffaf0;
font-size: 29rpx;
font-weight: 700;
}
.album-preview__close {
display: flex;
min-width: 44px;
min-height: 44px;
align-items: center;
justify-content: center;
color: #fffaf0;
font-size: 25rpx;
}
.album-preview__image {
width: 100%;
min-height: 0;
flex: 1;
}
.album-preview__footer {
padding: 20rpx 28rpx calc(30rpx + env(safe-area-inset-bottom));
color: #fffaf0;
text-align: center;
}
.album-preview__position {
font-size: 23rpx;
letter-spacing: 2rpx;
}
.album-preview__caption {
margin-top: 8rpx;
color: rgba(255, 250, 240, 0.78);
font-size: 22rpx;
}
@media (max-width: 340px) {
.album-detail-content {
padding-right: 16rpx;
padding-left: 16rpx;
}
.album-photo-grid {
gap: 10rpx;
}
}
</style>
+654 -3
View File
@@ -1,5 +1,656 @@
<!-- 页面编号F-09用途图片预览上传与失败状态 -->
<template><ModulePage page-id="f09" /></template>
<!-- 页面编号F-09用途照片选择说明上传进度与失败重试视觉候选 -->
<template>
<view
class="media-upload-page"
:class="`media-upload-state--${uploadState}`"
>
<ModulePageBackground module="family" />
<view class="media-upload-header"><PageHeader title="上传照片" /></view>
<view class="media-upload-content">
<view class="media-album-card">
<image
class="media-album-card__cover"
:src="mockLibrary[0].src"
:alt="mockLibrary[0].alt"
mode="aspectFill"
/>
<view class="media-album-card__copy">
<text class="media-album-card__eyebrow">当前相册</text>
<text class="media-album-card__title">2024 春节团圆</text>
<text class="media-album-card__limit">最多可选择 9 </text>
</view>
</view>
<view v-if="uploadState === 'permission'" class="media-permission-card">
<text class="media-state-card__eyebrow">照片访问权限</text>
<text class="media-state-card__title">需要照片访问权限</text>
<text class="media-state-card__copy"
>授权后才能选择要加入当前相册的照片当前仅展示 H5 审核状态</text
>
<view class="media-permission-action" @click="selectMockPhotos">
<AppButton block label="重新授权" />
</view>
</view>
<view v-else-if="uploadState === 'success'" class="media-success-card">
<text class="media-state-card__eyebrow">上传结果</text>
<text class="media-success-title">4 张照片已上传</text>
<text class="media-state-card__copy"
>照片已加入2024 春节团圆可以返回相册继续查看</text
>
<view class="media-success-action" @click="returnToAlbum">
<AppButton block label="返回当前相册" />
</view>
</view>
<template v-else>
<view class="media-section-heading">
<text>选择照片</text>
<text>{{ selectedPhotos.length }} / {{ MAX_PHOTOS }}</text>
</view>
<view class="media-photo-grid">
<view
v-for="(photo, index) in selectedPhotos"
:key="photo.id"
class="media-photo-tile"
:class="{
'media-photo-tile--active': index === activePhotoIndex,
'media-photo-tile--failed': photo.status === 'error',
}"
@click="selectPhoto(index)"
>
<image
class="media-photo-tile__image"
:src="photo.src"
:alt="photo.alt"
mode="aspectFill"
/>
<text class="media-photo-order">{{ index + 1 }}</text>
<text
v-if="index === activePhotoIndex"
class="media-photo-current"
>当前</text
>
<view
v-if="!isLocked"
class="media-photo-remove"
@click.stop="removePhoto(index)"
>
<text>删除</text>
</view>
<text
v-if="photo.status === 'error'"
class="media-photo-status media-photo-status--error"
>上传失败</text
>
<text
v-else-if="photo.status === 'uploading'"
class="media-photo-status media-photo-status--uploading"
>{{ photo.progress }}%</text
>
<text
v-else-if="photo.status === 'uploaded'"
class="media-photo-status media-photo-status--uploaded"
>已上传</text
>
</view>
<view
v-if="selectedPhotos.length < MAX_PHOTOS && !isLocked"
class="media-add-tile"
@click="selectedPhotos.length ? addMockPhoto() : selectMockPhotos()"
>
<text class="media-add-tile__title">{{
selectedPhotos.length ? "继续添加" : "选择照片"
}}</text>
<text class="media-add-tile__copy">{{
selectedPhotos.length ? `还可选择 ${MAX_PHOTOS - selectedPhotos.length}` : "最多 9 张"
}}</text>
</view>
</view>
<view v-if="selectedPhotos.length" class="media-description-panel">
<view class="media-batch-field">
<view class="media-field-heading">
<text>整批说明</text><text>必填</text>
</view>
<textarea
v-model="batchDescription"
:disabled="isLocked"
maxlength="120"
placeholder="例如:2024 年春节全家团圆留影"
placeholder-class="media-field-placeholder"
/>
</view>
<text v-if="validationMessage" class="media-field-error">{{
validationMessage
}}</text>
<view v-if="activePhoto" class="media-photo-field">
<view class="media-photo-editor">
<image
class="media-photo-editor__thumb"
:src="activePhoto.src"
:alt="activePhoto.alt"
mode="aspectFill"
/>
<view class="media-photo-editor__copy">
<text class="media-photo-editor__position"
> {{ activePhotoIndex + 1 }} /
{{ selectedPhotos.length }} </text
>
<text class="media-photo-editor__hint"
>正在编辑此照片的说明</text
>
</view>
</view>
<view class="media-field-heading">
<text>当前照片说明</text><text>选填</text>
</view>
<textarea
:value="activePhoto.note"
:disabled="isLocked"
maxlength="80"
placeholder="补充人物、时间或拍摄地点"
placeholder-class="media-field-placeholder"
@input="updateActiveNote"
/>
</view>
</view>
<template v-if="uploadState === 'uploading'">
<text class="media-progress-copy"
>正在上传 2/{{ selectedPhotos.length }}</text
>
<view class="media-progress-action">
<AppButton block label="正在上传" />
</view>
</template>
<view
v-else-if="uploadState === 'error'"
class="media-retry-action"
@click="retryFailed"
>
<AppButton block label="重试 2 张失败照片" />
</view>
<view v-else class="media-primary-action" @click="startUpload">
<AppButton
block
:label="
selectedPhotos.length
? `上传 ${selectedPhotos.length} 张照片`
: '选择照片'
"
/>
</view>
</template>
</view>
</view>
</template>
<script setup>
import ModulePage from "@/components/ModulePage.vue";
import { computed, ref } from "vue";
import { onLoad } from "@dcloudio/uni-app";
import AppButton from "@/components/AppButton.vue";
import ModulePageBackground from "@/components/ModulePageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
const MAX_PHOTOS = 9;
const uploadState = ref("initial");
const activePhotoIndex = ref(0);
const batchDescription = ref("");
const validationMessage = ref("");
const mockLibrary = [
{
id: "reunion",
src: "/static/assets/modules/family/f08/f08-reunion-hero.png",
alt: "春节团圆时三代家人的合影",
note: "",
},
{
id: "portrait",
src: "/static/assets/modules/family/f08/f08-family-portrait.png",
alt: "家人在院落前的春节合影",
note: "",
},
{
id: "table",
src: "/static/assets/modules/family/f08/f08-reunion-table.png",
alt: "家人围坐吃年夜饭",
note: "",
},
{
id: "home",
src: "/static/assets/modules/family/f08/f08-ancestral-home.png",
alt: "祖居院落的复古旧照",
note: "",
},
{
id: "ancestor",
src: "/static/assets/modules/family/f08/f08-ancestral-portrait.png",
alt: "老一辈家人在祖居门前的合影",
note: "",
},
];
const selectedPhotos = ref([]);
const activePhoto = computed(
() => selectedPhotos.value[activePhotoIndex.value] || null,
);
const isLocked = computed(() => uploadState.value === "uploading");
const makeSelectedPhotos = (state) =>
mockLibrary.slice(0, 4).map((photo, index) => ({
...photo,
note: "",
progress: state === "uploading" ? [100, 62, 35, 0][index] : 0,
status:
state === "error"
? index < 2
? "error"
: "uploaded"
: state,
}));
const selectMockPhotos = () => {
selectedPhotos.value = makeSelectedPhotos("selected");
activePhotoIndex.value = 0;
validationMessage.value = "";
uploadState.value = "selected";
};
const selectPhoto = (index) => {
if (!isLocked.value) activePhotoIndex.value = index;
};
const removePhoto = (index) => {
if (isLocked.value) return;
selectedPhotos.value.splice(index, 1);
activePhotoIndex.value = Math.max(
0,
Math.min(activePhotoIndex.value, selectedPhotos.value.length - 1),
);
};
const addMockPhoto = () => {
if (isLocked.value || selectedPhotos.value.length >= MAX_PHOTOS) return;
const nextIndex = selectedPhotos.value.length;
const next = mockLibrary[nextIndex % mockLibrary.length];
selectedPhotos.value.push({
...next,
id: `${next.id}-${nextIndex + 1}`,
note: "",
progress: 0,
status: "selected",
});
};
const updateActiveNote = (event) => {
if (!isLocked.value && activePhoto.value) {
activePhoto.value.note = event.detail.value;
}
};
const startUpload = () => {
if (!selectedPhotos.value.length) {
selectMockPhotos();
return;
}
if (!batchDescription.value.trim()) {
validationMessage.value = "请填写整批照片说明";
return;
}
validationMessage.value = "";
selectedPhotos.value = selectedPhotos.value.map((photo, index) => ({
...photo,
progress: [100, 62, 35, 0][index] || 0,
status: "uploading",
}));
uploadState.value = "uploading";
};
const retryFailed = () => {
selectedPhotos.value = selectedPhotos.value.map((photo, index) => ({
...photo,
progress: [100, 62, 35, 0][index] || 0,
status: "uploading",
}));
uploadState.value = "uploading";
};
const returnToAlbum = () => {
uni.redirectTo({ url: "/pages/family/f08-album-detail" });
};
onLoad((query) => {
uploadState.value = ["permission", "selected", "uploading", "error", "success"].includes(query.state)
? query.state
: "initial";
if (!["initial", "permission"].includes(uploadState.value)) {
selectedPhotos.value = makeSelectedPhotos(uploadState.value);
batchDescription.value = "春节团圆照片整理";
}
});
</script>
<style scoped lang="scss">
.media-upload-page {
position: relative;
min-height: 100vh;
overflow-x: hidden;
background: $paper;
}
.media-upload-header,
.media-upload-content {
position: relative;
z-index: 2;
}
.media-upload-content {
padding: 20rpx 24rpx calc(48rpx + env(safe-area-inset-bottom));
}
.media-album-card {
position: relative;
display: grid;
min-height: 154rpx;
box-sizing: border-box;
grid-template-columns: 132rpx minmax(0, 1fr);
gap: 20rpx;
align-items: center;
padding: 14rpx 20rpx;
border: 20rpx solid transparent;
border-image-source: url("/static/assets/modules/family/transparent/module-content-frame.png");
border-image-slice: 72 fill;
border-image-width: 58rpx;
border-image-repeat: stretch;
}
.media-album-card__cover {
width: 132rpx;
height: 132rpx;
border: 2rpx solid rgba(179, 133, 63, 0.62);
box-shadow: 0 6rpx 14rpx rgba(91, 60, 32, 0.12);
}
.media-album-card__copy text,
.media-state-card__eyebrow,
.media-state-card__title,
.media-state-card__copy,
.media-success-title,
.media-photo-status,
.media-progress-copy {
display: block;
}
.media-album-card__eyebrow {
color: $brand-red;
font-size: 21rpx;
letter-spacing: 2rpx;
}
.media-album-card__title {
margin-top: 7rpx;
color: $ink;
font-family: STKaiti, KaiTi, serif;
font-size: 31rpx;
font-weight: 700;
}
.media-album-card__limit {
margin-top: 7rpx;
color: $ink-muted;
font-size: 21rpx;
}
.media-section-heading {
display: flex;
align-items: center;
justify-content: space-between;
margin: 24rpx 8rpx 12rpx;
color: $brand-red;
font-size: 25rpx;
font-weight: 700;
}
.media-section-heading text:last-child {
color: $ink-muted;
font-size: 21rpx;
font-weight: 400;
}
.media-photo-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12rpx;
}
.media-photo-tile,
.media-add-tile {
position: relative;
min-width: 0;
min-height: 44px;
overflow: hidden;
box-sizing: border-box;
aspect-ratio: 1;
border: 2rpx solid rgba(179, 133, 63, 0.62);
background: rgba(247, 241, 231, 0.88);
}
.media-photo-tile--active {
outline: 4rpx solid rgba(159, 44, 35, 0.78);
outline-offset: -4rpx;
}
.media-photo-tile--failed {
border-style: dashed;
border-color: #9f2c23;
}
.media-photo-tile__image {
width: 100%;
height: 100%;
}
.media-photo-order,
.media-photo-current,
.media-photo-status {
position: absolute;
z-index: 2;
color: #fffaf0;
font-size: 20rpx;
}
.media-photo-order {
top: 7rpx;
left: 7rpx;
min-width: 34rpx;
padding: 3rpx 7rpx;
border-radius: 999px;
background: rgba(43, 31, 23, 0.78);
text-align: center;
}
.media-photo-current {
bottom: 7rpx;
left: 7rpx;
padding: 4rpx 8rpx;
background: rgba(74, 45, 27, 0.84);
}
.media-photo-remove {
position: absolute;
z-index: 3;
top: 0;
right: 0;
display: flex;
min-width: 44px;
min-height: 44px;
align-items: flex-start;
justify-content: flex-end;
color: #fffaf0;
font-size: 20rpx;
}
.media-photo-remove text {
padding: 7rpx 8rpx;
background: rgba(145, 36, 29, 0.88);
}
.media-photo-status {
right: 0;
bottom: 0;
left: 0;
padding: 7rpx 8rpx;
background: rgba(58, 43, 32, 0.82);
text-align: center;
}
.media-photo-status--error {
background: rgba(132, 35, 29, 0.92);
font-weight: 700;
}
.media-photo-status--uploaded {
background: rgba(55, 82, 51, 0.88);
}
.media-add-tile {
display: flex;
min-height: 44px;
flex-direction: column;
align-items: center;
justify-content: center;
border-style: dashed;
color: $brand-red;
text-align: center;
}
.media-add-tile__title {
font-size: 24rpx;
font-weight: 700;
}
.media-add-tile__copy {
margin-top: 6rpx;
color: $ink-muted;
font-size: 19rpx;
}
.media-description-panel {
margin-top: 22rpx;
}
.media-batch-field,
.media-photo-field {
padding: 18rpx 20rpx 20rpx;
border: 2rpx solid rgba(179, 133, 63, 0.55);
background: rgba(250, 246, 237, 0.76);
}
.media-photo-field {
margin-top: 16rpx;
}
.media-photo-editor {
display: grid;
grid-template-columns: 78rpx minmax(0, 1fr);
gap: 14rpx;
align-items: center;
margin-bottom: 16rpx;
padding-bottom: 14rpx;
border-bottom: 1px solid rgba(179, 133, 63, 0.32);
}
.media-photo-editor__thumb {
width: 78rpx;
height: 78rpx;
border: 2rpx solid rgba(159, 44, 35, 0.62);
}
.media-photo-editor__copy text {
display: block;
}
.media-photo-editor__position {
color: $ink;
font-size: 23rpx;
font-weight: 700;
}
.media-photo-editor__hint {
margin-top: 6rpx;
color: $ink-muted;
font-size: 20rpx;
}
.media-field-heading {
display: flex;
align-items: center;
justify-content: space-between;
color: $ink;
font-size: 24rpx;
font-weight: 700;
}
.media-field-heading text:last-child {
color: $brand-red;
font-size: 20rpx;
font-weight: 500;
}
.media-batch-field textarea,
.media-photo-field textarea {
width: 100%;
height: 92rpx;
min-height: 92rpx;
margin-top: 12rpx;
box-sizing: border-box;
color: $ink;
font-size: 23rpx;
line-height: 1.55;
}
.media-field-placeholder {
color: #9e8e79;
}
.media-field-error {
display: block;
margin: 9rpx 6rpx 0;
color: $brand-red;
font-size: 21rpx;
line-height: 1.45;
}
.media-primary-action,
.media-retry-action,
.media-progress-action {
margin-top: 24rpx;
}
.media-progress-copy {
margin-top: 22rpx;
color: $ink;
font-size: 23rpx;
text-align: center;
}
.media-permission-card,
.media-success-card {
position: relative;
min-height: 410rpx;
margin-top: 24rpx;
box-sizing: border-box;
padding: 98rpx 46rpx 58rpx;
border: 20rpx solid transparent;
border-image-source: url("/static/assets/modules/family/transparent/module-content-frame.png");
border-image-slice: 72 fill;
border-image-width: 64rpx;
border-image-repeat: stretch;
text-align: center;
}
.media-state-card__eyebrow {
color: $brand-red;
font-size: 22rpx;
letter-spacing: 2rpx;
}
.media-state-card__title,
.media-success-title {
margin-top: 12rpx;
color: $ink;
font-family: STKaiti, KaiTi, serif;
font-size: 35rpx;
font-weight: 700;
}
.media-state-card__copy {
margin-top: 16rpx;
color: $ink-muted;
font-size: 23rpx;
line-height: 1.65;
}
.media-permission-action,
.media-success-action {
margin-top: 30rpx;
}
@media (max-width: 340px) {
.media-upload-content {
padding-right: 16rpx;
padding-left: 16rpx;
}
.media-photo-grid {
gap: 8rpx;
}
.media-album-card {
grid-template-columns: 116rpx minmax(0, 1fr);
gap: 14rpx;
}
.media-album-card__cover {
width: 116rpx;
height: 116rpx;
}
}
</style>
+141 -3
View File
@@ -1,5 +1,143 @@
<!-- 页面编号F-10用途家族视频列表详情与待开放状态 -->
<template><ModulePage page-id="f10" /></template>
<!-- 页面编号F-10用途家族视频待开放状态 -->
<template>
<view class="video-status-page">
<ModulePageBackground module="family" />
<view class="video-status-header"><PageHeader title="家族视频" /></view>
<view class="video-status-content">
<view class="video-status-lead">
<image
src="/static/assets/modules/genealogy/transparent/section-divider.png"
mode="scaleToFill"
/>
<text>记录会动的家族记忆</text>
</view>
<view class="video-status-card">
<image
class="video-status-card__skin"
src="/static/assets/modules/family/transparent/module-content-frame.png"
mode="scaleToFill"
/>
<view class="video-status-card__body">
<text class="video-status-card__eyebrow">视频 · 服务说明</text>
<text class="video-status-card__title">视频服务暂未开放</text>
<text class="video-status-card__copy"
>开放后可在这里浏览家族影像与纪念视频</text
>
<view class="video-return-action" @click="returnToFamily">
<AppButton block label="返回家族首页" />
</view>
</view>
</view>
</view>
</view>
</template>
<script setup>
import ModulePage from "@/components/ModulePage.vue";
import AppButton from "@/components/AppButton.vue";
import ModulePageBackground from "@/components/ModulePageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
const returnToFamily = () => {
uni.reLaunch({ url: "/pages/family/f01-family-feed" });
};
</script>
<style scoped lang="scss">
.video-status-page {
position: relative;
min-height: 100vh;
overflow-x: hidden;
background: $paper;
}
.video-status-header,
.video-status-content {
position: relative;
z-index: 2;
}
.video-status-content {
padding: 16rpx 26rpx 56rpx;
}
.video-status-lead {
position: relative;
height: 56rpx;
margin: 0 12rpx 18rpx;
color: $ink-muted;
font-size: 23rpx;
text-align: center;
}
.video-status-lead image {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
}
.video-status-lead text {
position: relative;
z-index: 1;
display: flex;
align-items: center;
justify-content: center;
height: 100%;
}
.video-status-card {
position: relative;
width: 100%;
min-height: 330rpx;
text-align: center;
}
.video-status-card__skin {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
}
.video-status-card__body {
position: relative;
z-index: 1;
padding: 70rpx 60rpx 48rpx;
}
.video-status-card__eyebrow,
.video-status-card__title,
.video-status-card__copy {
display: block;
}
.video-status-card__eyebrow {
color: $brand-red;
font-size: 23rpx;
letter-spacing: 3rpx;
}
.video-status-card__title {
margin-top: 14rpx;
color: $ink;
font-family: STKaiti, KaiTi, serif;
font-size: 35rpx;
font-weight: 700;
}
.video-status-card__copy {
margin-top: 17rpx;
color: $ink-muted;
font-size: 24rpx;
line-height: 1.6;
}
.video-return-action {
margin: 28rpx auto 0;
}
@media (min-width: 400px) {
.video-status-content {
padding-right: 34rpx;
padding-left: 34rpx;
}
}
@media (max-width: 340px) {
.video-status-content {
padding-right: 20rpx;
padding-left: 20rpx;
}
.video-status-card__body {
padding-right: 44rpx;
padding-left: 44rpx;
}
}
</style>
+12 -5
View File
@@ -194,8 +194,14 @@ const openItem = (item) => uni.navigateTo({ url: item.url });
}
.profile-hero {
position: relative;
display: grid;
grid-template-columns: 42rpx minmax(0, 1fr);
width: 100%;
min-height: 252rpx;
align-items: center;
gap: 30rpx;
padding: 34rpx 52rpx 32rpx 40rpx;
box-sizing: border-box;
overflow: hidden;
}
.profile-hero__frame {
@@ -203,6 +209,7 @@ const openItem = (item) => uni.navigateTo({ url: item.url });
inset: 0;
width: 100%;
height: 100%;
pointer-events: none;
}
.profile-hero__hall {
position: absolute;
@@ -211,6 +218,7 @@ const openItem = (item) => uni.navigateTo({ url: item.url });
width: 330rpx;
height: 122rpx;
opacity: 0.12;
pointer-events: none;
}
.profile-hero__cloud {
position: absolute;
@@ -219,11 +227,10 @@ const openItem = (item) => uni.navigateTo({ url: item.url });
width: 96rpx;
height: 52rpx;
opacity: 0.32;
pointer-events: none;
}
.profile-hero__label {
position: absolute;
top: 50%;
left: 40rpx;
position: relative;
z-index: 2;
display: flex;
width: 42rpx;
@@ -240,7 +247,6 @@ const openItem = (item) => uni.navigateTo({ url: item.url });
font-family: STKaiti, KaiTi, serif;
font-size: 21rpx;
line-height: 1.12;
transform: translateY(-50%);
}
.profile-identity {
position: relative;
@@ -250,7 +256,6 @@ const openItem = (item) => uni.navigateTo({ url: item.url });
box-sizing: border-box;
align-items: center;
gap: 24rpx;
padding: 34rpx 52rpx 32rpx 112rpx;
}
.profile-identity__seal {
width: 92rpx;
@@ -291,6 +296,7 @@ const openItem = (item) => uni.navigateTo({ url: item.url });
inset: 0;
width: 100%;
height: 100%;
pointer-events: none;
}
.profile-scroll-notice__copy {
position: relative;
@@ -395,6 +401,7 @@ const openItem = (item) => uni.navigateTo({ url: item.url });
width: 100%;
height: 220rpx;
min-height: 118px;
pointer-events: none;
}
.profile-error__copy {
position: relative;
+18 -10
View File
@@ -201,24 +201,28 @@ onUnmounted(() => {
}
.people-search {
position: relative;
display: grid;
grid-template-columns: minmax(0, 1fr) 126rpx;
width: 100%;
height: 82rpx;
min-height: 44px;
align-items: center;
}
.people-search > image {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
pointer-events: none;
}
.people-search input {
position: absolute;
position: relative;
z-index: 1;
top: 0;
right: 126rpx;
bottom: 0;
left: 31rpx;
width: 100%;
min-width: 0;
height: 100%;
padding-left: 31rpx;
box-sizing: border-box;
color: $ink;
font-size: 24rpx;
}
@@ -226,10 +230,8 @@ onUnmounted(() => {
color: #8a7965;
}
.people-search__action {
position: absolute;
position: relative;
z-index: 2;
top: 0;
right: 0;
display: flex;
width: 126rpx;
height: 100%;
@@ -245,9 +247,13 @@ onUnmounted(() => {
}
.person-card {
position: relative;
display: flex;
width: 100%;
height: clamp(92px, 190rpx, 108px);
align-items: center;
margin-top: 12px;
padding: 16% 10%;
box-sizing: border-box;
}
.person-card:first-child {
margin-top: 0;
@@ -257,12 +263,13 @@ onUnmounted(() => {
inset: 0;
width: 100%;
height: 100%;
pointer-events: none;
}
.person-card__copy {
position: absolute;
position: relative;
z-index: 1;
inset: 16% 10%;
display: flex;
width: 100%;
flex-direction: column;
justify-content: center;
}
@@ -300,6 +307,7 @@ onUnmounted(() => {
width: 100%;
height: 220rpx;
min-height: 118px;
pointer-events: none;
}
.people-result-empty > view,
.people-state-card > view {
+153 -3
View File
@@ -1,5 +1,155 @@
<!-- 页面编号R-02用途人物录详情与编辑 -->
<template><ModulePage page-id="r02" /></template>
<!-- 页面编号R-02用途人物录详情同页编辑与受控状态 -->
<template>
<view class="person-detail-page" :class="`person-detail-state--${personState}`">
<ModulePageBackground module="records" />
<view class="person-detail-header"><PageHeader :title="personState === 'edit' ? '编辑人物' : '人物详情'" /></view>
<view v-if="personState === 'loading'" class="person-detail-loading">
<AppLoading text="正在读取人物档案" description="请稍候,正在整理人物资料。" />
</view>
<view v-else class="person-detail-content">
<template v-if="['detail', 'edit', 'privacy'].includes(personState)">
<view class="person-identity-card">
<image class="person-card-skin" src="/static/assets/modules/records/transparent/r01-person-name-card.png" mode="scaleToFill" />
<view class="person-identity-card__copy">
<text class="person-identity-card__name">{{ person.name }}</text>
<text class="person-identity-card__meta">{{ person.role }} · {{ person.generation }} </text>
<text class="person-identity-card__hint">人物录档案</text>
</view>
</view>
</template>
<template v-if="personState === 'detail'">
<view v-for="item in detailSections" :key="item.title" class="person-archive-card">
<image class="person-card-skin" src="/static/assets/modules/records/transparent/module-content-frame.png" mode="scaleToFill" />
<view><text>{{ item.title }}</text><text>{{ item.copy }}</text></view>
</view>
<view class="person-edit-action" @click="enterEdit"><AppButton block label="编辑人物" /></view>
</template>
<template v-else-if="personState === 'edit'">
<view v-for="field in shortFields" :key="field.key" class="person-field">
<image class="person-card-skin" src="/static/assets/modules/records/transparent/module-field-frame.png" mode="scaleToFill" />
<text class="person-field__label">{{ field.label }}</text>
<input v-model="draft[field.key]" :type="field.key === 'generation' ? 'number' : 'text'" :placeholder="`请输入${field.label}`" />
<text v-if="errors[field.key]" class="person-field-error">{{ errors[field.key] }}</text>
</view>
<view v-for="field in longFields" :key="field.key" class="person-long-field">
<image class="person-card-skin" src="/static/assets/modules/records/transparent/module-content-frame.png" mode="scaleToFill" />
<text class="person-long-field__label">{{ field.label }}</text>
<textarea v-model="draft[field.key]" :placeholder="`请输入${field.label}`" />
</view>
<view class="person-edit-actions">
<view class="person-save-action" @click="savePerson"><AppButton block label="保存人物" /></view>
<view class="person-cancel-action" @click="cancelEdit"><AppButton type="secondary" block label="取消编辑" /></view>
</view>
</template>
<view v-else-if="personState === 'privacy'" class="person-state-card">
<image class="person-card-skin" src="/static/assets/modules/records/transparent/module-content-frame.png" mode="scaleToFill" />
<view><text>部分资料未公开</text><text>人物小传与家族印记受隐私设置保护当前只展示公开身份</text></view>
<view class="person-state-action" @click="returnToPeople"><AppButton block label="返回人物录" /></view>
</view>
<view v-else class="person-state-card">
<image class="person-card-skin" src="/static/assets/modules/records/transparent/module-content-frame.png" mode="scaleToFill" />
<view>
<text>{{ personState === 'expired' ? '人物档案已失效' : '人物档案暂不可用' }}</text>
<text>{{ personState === 'expired' ? '这份人物资料已无法查看,请返回人物录选择其他档案。' : '请稍后重新查看,已有资料不会受到影响。' }}</text>
</view>
<view class="person-state-action" @click="personState === 'expired' ? returnToPeople() : restoreDetail()">
<AppButton :type="personState === 'error' ? 'secondary' : 'primary'" block :label="personState === 'expired' ? '返回人物录' : '重新查看'" />
</view>
</view>
</view>
<AppToast :visible="toastVisible" message="人物资料已保存" />
</view>
</template>
<script setup>
import ModulePage from "@/components/ModulePage.vue";
import { computed, onUnmounted, reactive, ref } from "vue";
import { onLoad } from "@dcloudio/uni-app";
import AppButton from "@/components/AppButton.vue";
import AppLoading from "@/components/AppLoading.vue";
import AppToast from "@/components/AppToast.vue";
import ModulePageBackground from "@/components/ModulePageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
const people = [
{ id: "1", name: "汤文正", role: "家谱管理员", generation: "18", biography: "勤于修谱,常年整理族中旧照片与口述资料。", legacy: "参与维护汤氏家谱与家风家训。" },
{ id: "2", name: "汤淑华", role: "家族长辈", generation: "17", biography: "熟悉家族往事,愿意为后辈讲述旧时记忆。", legacy: "长期参与家族节庆与敬老活动。" },
{ id: "3", name: "汤文清", role: "青年代表", generation: "19", biography: "协助整理电子家谱和家族影像资料。", legacy: "推动年轻成员共同参与家谱维护。" },
];
const person = reactive({ ...people[0] });
const draft = reactive({ name: "", role: "", generation: "", biography: "", legacy: "" });
const errors = reactive({ name: "", role: "", generation: "" });
const personState = ref("loading");
const toastVisible = ref(false);
let toastTimer = null;
const shortFields = [{ key: "name", label: "姓名" }, { key: "role", label: "身份" }, { key: "generation", label: "世代" }];
const longFields = [{ key: "biography", label: "人物小传" }, { key: "legacy", label: "家族印记" }];
const detailSections = computed(() => [{ title: "人物小传", copy: person.biography }, { title: "家族印记", copy: person.legacy }]);
const copyToDraft = () => Object.assign(draft, { name: person.name, role: person.role, generation: person.generation, biography: person.biography, legacy: person.legacy });
const clearErrors = () => Object.assign(errors, { name: "", role: "", generation: "" });
const enterEdit = () => { copyToDraft(); clearErrors(); personState.value = "edit"; };
const cancelEdit = () => { copyToDraft(); clearErrors(); personState.value = "detail"; };
const savePerson = () => {
clearErrors();
if (!String(draft.name).trim()) errors.name = "请填写姓名";
if (!String(draft.role).trim()) errors.role = "请填写身份";
if (!String(draft.generation).trim()) errors.generation = "请填写世代";
if (errors.name || errors.role || errors.generation) return;
Object.assign(person, { ...draft, name: draft.name.trim(), role: draft.role.trim(), generation: String(draft.generation).trim() });
personState.value = "detail";
toastVisible.value = true;
if (toastTimer) clearTimeout(toastTimer);
toastTimer = setTimeout(() => { toastVisible.value = false; toastTimer = null; }, 1800);
};
const returnToPeople = () => uni.reLaunch({ url: "/pages/records/r01-people-list" });
const restoreDetail = () => { personState.value = "detail"; };
onLoad((query) => {
const selected = people.find((item) => item.id === String(query.personId || ""));
if (selected) Object.assign(person, selected);
copyToDraft();
const requested = ["loading", "privacy", "expired", "error", "edit"].includes(query.state) ? query.state : "detail";
personState.value = selected ? requested : "error";
});
onUnmounted(() => { if (toastTimer) clearTimeout(toastTimer); });
</script>
<style scoped lang="scss">
.person-detail-page { min-height: 100vh; overflow-x: hidden; background: $paper; }
.person-detail-loading { min-height: calc(100vh - 100rpx); }
.person-detail-content { padding: 18rpx 24rpx 72rpx; }
.person-identity-card,.person-archive-card,.person-field,.person-long-field,.person-state-card { position: relative; }
.person-card-skin { position: absolute; inset: 0; z-index: 0; width: 100%; height: 100%; pointer-events: none; }
.person-identity-card { display: flex; min-height: 190rpx; padding: 30rpx 10%; box-sizing: border-box; }
.person-identity-card__copy { position: relative; z-index: 1; display: flex; flex: 1; flex-direction: column; justify-content: center; }
.person-identity-card__copy text { display: block; }
.person-identity-card__name { color: $ink; font-family: STKaiti,KaiTi,serif; font-size: 38rpx; font-weight: 700; }
.person-identity-card__meta { margin-top: 8rpx; color: $ink-muted; font-size: 25rpx; font-weight: 600; }
.person-identity-card__hint { margin-top: 8rpx; color: #806a51; font-size: 21rpx; }
.person-archive-card { min-height: 154rpx; margin-top: 14rpx; padding: 32rpx 42rpx; box-sizing: border-box; }
.person-archive-card > view { position: relative; z-index: 1; }
.person-archive-card text { display: block; }
.person-archive-card text:first-child,.person-long-field__label { color: $brand-red; font-size: 24rpx; font-weight: 700; }
.person-archive-card text:last-child { margin-top: 11rpx; color: $ink; font-size: 24rpx; line-height: 1.55; }
.person-edit-action { margin-top: 20rpx; }
.person-field { display: grid; grid-template-columns: auto minmax(0, 1fr); align-items: center; gap: 8rpx 24rpx; min-height: 92rpx; margin-top: 12rpx; padding: 18rpx 28rpx; box-sizing: border-box; }
.person-field__label { position: relative; z-index: 1; color: $ink; font-size: 23rpx; font-weight: 700; }
.person-field input { position: relative; z-index: 1; width: 100%; min-width: 0; height: 56rpx; color: $ink; font-size: 23rpx; text-align: right; }
.person-field-error { position: relative; z-index: 1; grid-column: 1 / -1; display: block; color: $brand-red; font-size: 21rpx; text-align: right; }
.person-long-field { display: flex; flex-direction: column; min-height: 210rpx; margin-top: 14rpx; padding: 28rpx 38rpx; box-sizing: border-box; }
.person-long-field__label,.person-long-field textarea { position: relative; z-index: 1; }
.person-long-field textarea { width: 100%; height: 112rpx; min-height: 112rpx; margin-top: 14rpx; color: $ink; font-size: 23rpx; line-height: 1.5; }
.person-edit-actions { display: flex; flex-direction: column; gap: 14rpx; margin-top: 20rpx; }
.person-state-card { display: flex; flex-direction: column; min-height: 300rpx; margin-top: 26rpx; padding: 72rpx 54rpx 42rpx; box-sizing: border-box; text-align: center; }
.person-state-card > view { position: relative; z-index: 1; }
.person-state-card text { display: block; }
.person-state-card text:first-child { color: $ink; font-family: STKaiti,KaiTi,serif; font-size: 34rpx; font-weight: 700; }
.person-state-card text:last-child { margin-top: 16rpx; color: $ink-muted; font-size: 24rpx; line-height: 1.6; }
.person-state-action { margin: 24rpx 48rpx 0; }
@media (min-width: 400px) { .person-detail-content { padding-right: 32rpx; padding-left: 32rpx; } }
@media (max-width: 340px) { .person-detail-content { padding-right: 18rpx; padding-left: 18rpx; } .person-identity-card__name { font-size: 35rpx; } }
</style>
+89 -97
View File
@@ -25,8 +25,28 @@
</view>
</view>
<view class="tree-stage">
<view v-if="treeState === 'tree'" class="generation-rail">
<view
v-for="row in generationRows"
:key="row.generation"
class="generation-band"
:style="generationRowStyle(row)"
>
<image
src="/static/assets/modules/genealogy/transparent/section-divider.png"
mode="scaleToFill"
/>
<view class="generation-band__copy">
<text>{{ row.label }}</text>
<text>{{ row.summary }}</text>
</view>
</view>
</view>
<scroll-view
class="tree-scroll"
:class="{ 'tree-scroll--lineage': treeState === 'tree' }"
scroll-x
:scroll-left="initialScrollLeft"
:show-scrollbar="false"
@@ -46,28 +66,6 @@
<text>同代分支可左右查看</text>
</view>
<view class="generation-band generation-band--twelve">
<image
src="/static/assets/modules/genealogy/transparent/section-divider.png"
mode="scaleToFill"
/>
<text>第十二世 · 始祖</text>
</view>
<view class="generation-band generation-band--thirteen">
<image
src="/static/assets/modules/genealogy/transparent/section-divider.png"
mode="scaleToFill"
/>
<text>第十三世 · 两房</text>
</view>
<view class="generation-band generation-band--fourteen">
<image
src="/static/assets/modules/genealogy/transparent/section-divider.png"
mode="scaleToFill"
/>
<text>第十四世 · 三支</text>
</view>
<view class="lineage-connector lineage-connector--root" />
<view class="lineage-connector lineage-connector--root-branch" />
<view class="lineage-connector lineage-connector--gen13-left" />
@@ -128,6 +126,7 @@
</view>
</view>
</scroll-view>
</view>
<view v-if="treeState === 'tree' && selected" class="member-sheet">
<image
@@ -143,20 +142,17 @@
>
</view>
<view class="sheet-actions">
<view class="sheet-action sheet-member-action" @click="toMember">
<image
src="/static/assets/foundation/transparent/a01-secondary-button-v2.png"
mode="aspectFit"
<AppButton
class="sheet-action"
type="secondary"
label="查看资料"
@click="toMember"
/>
<text>查看资料</text>
</view>
<view class="sheet-action" @click="toAddRelative">
<image
src="/static/assets/foundation/transparent/a01-primary-button-v2.png"
mode="aspectFit"
<AppButton
class="sheet-action"
label="添加亲属"
@click="toAddRelative"
/>
<text>添加亲属</text>
</view>
</view>
</view>
</view>
@@ -165,6 +161,7 @@
<script setup>
import { computed, ref } from "vue";
import { onLoad } from "@dcloudio/uni-app";
import AppButton from "@/components/AppButton.vue";
import AppLoading from "@/components/AppLoading.vue";
import ModulePageBackground from "@/components/ModulePageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
@@ -173,7 +170,12 @@ import { genealogyContext } from "@/utils/genealogy-context.js";
const genealogyId = ref("");
const treeState = ref("loading");
const selected = ref(null);
const initialScrollLeft = ref(20);
const initialScrollLeft = ref(90);
const generationRows = [
{ generation: 12, label: "第十二世", summary: "始祖", y: 135 },
{ generation: 13, label: "第十三世", summary: "两房", y: 350 },
{ generation: 14, label: "第十四世", summary: "三支", y: 650 },
];
const members = ref([
{
id: 101,
@@ -287,6 +289,9 @@ const nodeStyle = (member) => ({
left: `${member.x}rpx`,
top: `${member.y}rpx`,
});
const generationRowStyle = (row) => ({
top: `${row.y - 88}rpx`,
});
const handleStateAction = () => {
if (treeState.value === "empty") {
uni.navigateTo({
@@ -324,7 +329,7 @@ const toAddRelative = () =>
}
.tree-page__header,
.tree-toolbar,
.tree-scroll,
.tree-stage,
.member-sheet {
position: relative;
z-index: 2;
@@ -355,11 +360,29 @@ const toAddRelative = () =>
color: $brand-red;
font-size: 23rpx;
}
.tree-stage {
position: relative;
height: calc(100vh - 272rpx);
}
.tree-scroll {
width: 100%;
height: calc(100vh - 272rpx);
height: 100%;
white-space: nowrap;
}
.generation-rail {
position: absolute;
top: 0;
bottom: 0;
left: 0;
z-index: 4;
width: 190rpx;
box-sizing: border-box;
border-right: 1rpx solid rgba(143, 108, 63, 0.2);
}
.tree-scroll--lineage {
width: calc(100% - 190rpx);
margin-left: 190rpx;
}
.tree-canvas {
position: relative;
width: 900rpx;
@@ -380,10 +403,10 @@ const toAddRelative = () =>
}
.generation-band {
position: absolute;
left: 24rpx;
left: 12rpx;
z-index: 3;
width: 184rpx;
height: 38rpx;
width: 166rpx;
height: 58rpx;
color: #8f6c3f;
font-size: 22rpx;
text-align: center;
@@ -394,22 +417,18 @@ const toAddRelative = () =>
width: 100%;
height: 100%;
}
.generation-band text {
.generation-band__copy {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
}
.generation-band--twelve {
top: 48rpx;
}
.generation-band--thirteen {
top: 250rpx;
}
.generation-band--fourteen {
top: 522rpx;
.generation-band__copy text:last-child {
margin-top: 1rpx;
font-size: 19rpx;
}
.lineage-connector {
position: absolute;
@@ -528,12 +547,11 @@ const toAddRelative = () =>
color: $brand-red;
}
.tree-state-card {
position: absolute;
top: 150rpx;
right: 24rpx;
left: 24rpx;
position: relative;
z-index: 2;
width: calc(100% - 48rpx);
height: 360rpx;
margin: 150rpx 24rpx 0;
text-align: center;
white-space: normal;
}
@@ -597,18 +615,21 @@ const toAddRelative = () =>
right: 18rpx;
bottom: calc(14rpx + env(safe-area-inset-bottom));
left: 18rpx;
height: 190rpx;
display: flex;
height: 240rpx;
flex-direction: column;
padding: 38rpx 44rpx 24rpx;
box-sizing: border-box;
}
.member-sheet__skin {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
pointer-events: none;
}
.member-sheet__copy {
position: absolute;
top: 38rpx;
left: 44rpx;
position: relative;
z-index: 1;
}
.sheet-name {
@@ -625,38 +646,16 @@ const toAddRelative = () =>
font-size: 22rpx;
}
.sheet-actions {
position: absolute;
right: 36rpx;
bottom: 30rpx;
position: relative;
z-index: 1;
display: flex;
gap: 12rpx;
justify-content: center;
gap: 16rpx;
margin-top: auto;
}
.sheet-action {
position: relative;
width: 148rpx;
height: 60rpx;
}
.sheet-action image {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
}
.sheet-action text {
position: relative;
z-index: 1;
display: flex;
align-items: center;
justify-content: center;
height: 100%;
color: $ink;
font-family: "Microsoft YaHei", sans-serif;
font-size: 22rpx;
font-weight: 700;
}
.sheet-action:last-child text {
color: #fff9ed;
width: 250rpx;
min-height: 88rpx;
}
@media (max-width: 360px) {
@@ -668,18 +667,11 @@ const toAddRelative = () =>
gap: 14rpx;
}
.member-sheet {
height: 206rpx;
}
.member-sheet__copy {
top: 32rpx;
left: 36rpx;
}
.sheet-actions {
right: 28rpx;
bottom: 26rpx;
}
.sheet-action {
width: 160rpx;
height: 240rpx;
padding-top: 32rpx;
padding-right: 36rpx;
padding-left: 36rpx;
padding-bottom: 20rpx;
}
}
</style>
+4 -1
View File
@@ -520,12 +520,15 @@ const capture = async () => {
await waitForLoadedImages(send)
await sleep(action?.endsWith('-submitting') ? 80 : renderSettleMilliseconds)
const screenshot = await send('Page.captureScreenshot', { format: 'png', fromSurface: true, captureBeyondViewport: false })
await send('Page.bringToFront')
const screenshot = await send('Page.captureScreenshot', { format: 'png', fromSurface: action !== 'from-view', captureBeyondViewport: false })
fs.mkdirSync(path.dirname(output), { recursive: true })
fs.writeFileSync(output, Buffer.from(screenshot.data, 'base64'))
process.stdout.write(`CAPTURED ${output}\n`)
} finally {
if (action !== 'keep-view') {
try { await send('Emulation.clearDeviceMetricsOverride') } catch (_) {}
}
socket.close()
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 MiB

+1 -1
View File
@@ -2,7 +2,7 @@ $ErrorActionPreference = 'Stop'
$root = Split-Path -Parent $PSScriptRoot
$capture = Get-Content -LiteralPath (Join-Path $root 'scripts/capture-chrome-page.js') -Raw -Encoding utf8
foreach ($required in @('prepareFSeriesState', 'f02-validation', 'module-preview-toast', 'waitForPageLoad')) {
foreach ($required in @('prepareFSeriesState', 'f02-validation', 'module-preview-toast', 'waitForPageLoad', "fromSurface: action !== 'from-view'")) {
if ($capture -notmatch [regex]::Escape($required)) { throw "F series capture state missing: $required" }
}
if ($capture -notmatch 'await prepareFSeriesState\(send, action\)') { throw 'Capture runner does not prepare F series states' }
+407
View File
@@ -0,0 +1,407 @@
[
{
"file": "components/AppDialog.vue",
"selector": ".app-dialog-layer",
"reason": "全屏弹窗遮罩"
},
{
"file": "components/AppDialog.vue",
"selector": ".app-dialog__skin",
"reason": "弹窗内部装饰皮肤"
},
{
"file": "components/AppToast.vue",
"selector": ".app-toast",
"reason": "跨页面轻提示"
},
{
"file": "components/PageHeader.vue",
"selector": ".page-header",
"reason": "用户指定的固定顶部导航栏"
},
{
"file": "components/AppTabbar.vue",
"selector": ".app-tabbar",
"reason": "用户指定的固定底部导航栏"
},
{
"file": "components/PageHeader.vue",
"selector": ".header-texture",
"reason": "固定顶部栏内部纯装饰纹理层"
},
{
"file": "components/PageHeader.vue",
"selector": ".header-hall",
"reason": "固定顶部栏内部纯装饰祠堂层"
},
{
"file": "components/PageHeader.vue",
"selector": ".notice-dot",
"reason": "依附通知按钮的局部未读角标"
},
{
"file": "pages/records/r02-person-detail.vue",
"selector": ".person-card-skin",
"reason": "依附卡片容器且不承担排版的装饰框层"
},
{
"file": "components/AppButton.vue",
"selector": ".app-button__skin",
"reason": "依附按钮容器且不承担文字排版的卷轴皮肤"
},
{
"file": "components/GenealogyCard.vue",
"selector": ".row-frame",
"reason": "依附家谱卡片容器的装饰框层"
},
{
"file": "components/GenealogyCard.vue",
"selector": ".surname-seal-frame",
"reason": "依附姓氏印章容器的装饰框层"
},
{
"file": "components/GenealogyPageBackground.vue",
"selector": ".genealogy-page-background",
"reason": "不参与排版且覆盖视口的独立家谱背景层"
},
{
"file": "components/GenealogyPageBackground.vue",
"selector": ".genealogy-page-background__art",
"reason": "独立家谱背景层内部的底部背景画面"
},
{
"file": "components/ModulePageBackground.vue",
"selector": ".module-page-background",
"reason": "不参与排版且覆盖视口的独立模块背景层"
},
{
"file": "components/ModulePageBackground.vue",
"selector": ".module-page-background__image",
"reason": "独立模块背景层内部的底部背景画面"
},
{
"file": "components/ModulePage.vue",
"selector": ".module-lead image",
"reason": "依附模块导语容器且不承担文字排版的分隔装饰"
},
{
"file": "components/ModulePage.vue",
"selector": ".form-row image, .detail-card image, .settings-row image, .list-card > image, .timeline-row > image",
"reason": "依附各内容容器且不承担正文排版的装饰框层"
},
{
"file": "components/ModulePage.vue",
"selector": ".status-card__skin",
"reason": "依附状态卡容器且不承担正文排版的装饰框层"
},
{
"file": "components/tree/TreeMemberForm.vue",
"selector": ".member-form-panel__skin",
"reason": "依附成员表单容器且不承担正文排版的装饰框层"
},
{
"file": "components/tree/TreeMemberForm.vue",
"selector": ".member-field image",
"reason": "依附成员字段容器且不承担表单排版的装饰框层"
},
{
"file": "components/tree/TreeMemberForm.vue",
"selector": ".member-form-action image",
"reason": "依附成员表单按钮且不承担文字排版的卷轴皮肤"
},
{
"file": "pages/records/r01-people-list.vue",
"selector": ".people-search > image",
"reason": "依附人物搜索容器且不承担输入排版的装饰框层"
},
{
"file": "pages/records/r01-people-list.vue",
"selector": ".person-card__skin",
"reason": "依附人物卡片且不承担正文排版的装饰框层"
},
{
"file": "pages/records/r01-people-list.vue",
"selector": ".people-result-empty > image, .people-state-card > image",
"reason": "依附人物状态卡且不承担正文排版的装饰框层"
},
{
"file": "pages/family/f01-family-feed.vue",
"selector": ".feed-shortcuts__skin",
"reason": "依附快捷入口容器且不承担文字排版的卷轴皮肤"
},
{
"file": "pages/family/f01-family-feed.vue",
"selector": ".feed-card__skin, .feed-state-card > image, .feed-action image",
"reason": "依附动态卡片、状态卡和按钮且不承担正文排版的装饰皮肤"
},
{
"file": "pages/family/f02-publish-feed.vue",
"selector": ".publish-panel__skin",
"reason": "依附发布面板且不承担正文排版的装饰框层"
},
{
"file": "pages/family/f02-publish-feed.vue",
"selector": ".publish-field image",
"reason": "依附发布文本域且不承担输入排版的装饰框层"
},
{
"file": "pages/family/f06-article-editor.vue",
"selector": ".editor-control > image",
"reason": "依附文章编辑控件且不承担输入排版的装饰框层"
},
{
"file": "pages/family/f08-album-detail.vue",
"selector": ".album-hero-photo, .album-photo-tile__image",
"reason": "在固定比例照片格内执行裁切的媒体画面层"
},
{
"file": "pages/family/f08-album-detail.vue",
"selector": ".album-photo-tile__caption",
"reason": "依附照片格底部的局部说明叠层"
},
{
"file": "pages/family/f08-album-detail.vue",
"selector": ".album-preview",
"reason": "用户触发的全屏照片预览层"
},
{
"file": "pages/family/f09-media-upload.vue",
"selector": ".media-photo-order, .media-photo-current, .media-photo-status",
"reason": "依附照片缩略图的顺序与状态角标"
},
{
"file": "pages/family/f09-media-upload.vue",
"selector": ".media-photo-remove",
"reason": "依附照片缩略图右上角的删除操作"
},
{
"file": "pages/family/f10-video-list.vue",
"selector": ".video-status-lead image",
"reason": "依附视频状态导语的分隔装饰层"
},
{
"file": "pages/family/f10-video-list.vue",
"selector": ".video-status-card__skin",
"reason": "依附视频状态卡且不承担正文排版的装饰框层"
},
{
"file": "pages/notification/n01-message-center.vue",
"selector": ".notice-card__skin",
"reason": "依附消息卡且不承担正文排版的装饰框层"
},
{
"file": "pages/notification/n01-message-center.vue",
"selector": ".notice-state-card__skin",
"reason": "依附消息状态卡且不承担正文排版的装饰框层"
},
{
"file": "pages/profile/m01-profile-home.vue",
"selector": ".profile-hero__frame",
"reason": "依附个人摘要容器的装饰框层"
},
{
"file": "pages/profile/m01-profile-home.vue",
"selector": ".profile-hero__hall",
"reason": "个人摘要容器内的纯装饰祠堂层"
},
{
"file": "pages/profile/m01-profile-home.vue",
"selector": ".profile-hero__cloud",
"reason": "个人摘要容器内的纯装饰云纹层"
},
{
"file": "pages/profile/m01-profile-home.vue",
"selector": ".profile-scroll-notice__skin",
"reason": "依附提醒入口且不承担文字排版的卷轴皮肤"
},
{
"file": "pages/profile/m01-profile-home.vue",
"selector": ".profile-error__skin",
"reason": "依附个人资料错误卡且不承担正文排版的装饰框层"
},
{
"file": "pages/tree/t01-tree-overview.vue",
"selector": ".lineage-connector",
"reason": "依附世系画布且不承担节点排版的关系线绘制层"
},
{
"file": "pages/tree/t01-tree-overview.vue",
"selector": ".tree-state-card__skin",
"reason": "依附世系状态卡且不承担正文排版的装饰框层"
},
{
"file": "pages/tree/t01-tree-overview.vue",
"selector": ".tree-state-card__action image",
"reason": "依附世系状态按钮且不承担文字排版的卷轴皮肤"
},
{
"file": "pages/tree/t01-tree-overview.vue",
"selector": ".member-sheet",
"reason": "用户选择节点后显示的固定底部详情抽屉"
},
{
"file": "pages/tree/t01-tree-overview.vue",
"selector": ".member-sheet__skin",
"reason": "固定底部详情抽屉内部的装饰框层"
},
{
"file": "pages/auth/a01-entry.vue",
"selector": ".brand-seal",
"reason": "依附登录品牌标题的印章装饰"
},
{
"file": "pages/auth/a01-entry.vue",
"selector": ".button-skin",
"reason": "依附登录按钮且不承担文字排版的卷轴皮肤"
},
{
"file": "pages/auth/a01-entry.vue",
"selector": ".feedback-toast",
"reason": "登录页面跨内容轻提示"
},
{
"file": "pages/auth/a01-entry.vue",
"selector": ".login-tab.active::after",
"reason": "依附激活登录标签的局部下划线指示"
},
{
"file": "pages/auth/a01-entry.vue",
"selector": ".login-tab:first-child::before",
"reason": "登录标签组内部的局部分隔装饰"
},
{
"file": "pages/auth/a01-entry.vue",
"selector": ".page-backdrop",
"reason": "不参与登录内容排版的页面背景层"
},
{
"file": "pages/auth/a01-entry.vue",
"selector": ".verification-layer",
"reason": "用户触发的验证码弹窗遮罩"
},
{
"file": "pages/auth/a01-entry.vue",
"selector": ".verification-dialog__skin",
"reason": "验证码弹窗内部装饰框层"
},
{
"file": "pages/auth/a04-register.vue",
"selector": ".back-button",
"reason": "认证全屏页左上角返回操作"
},
{
"file": "pages/auth/a04-register.vue",
"selector": ".brand-seal",
"reason": "依附注册品牌标题的印章装饰"
},
{
"file": "pages/auth/a04-register.vue",
"selector": ".feedback-toast",
"reason": "注册页面跨内容轻提示"
},
{
"file": "pages/auth/a04-register.vue",
"selector": ".page-backdrop",
"reason": "不参与注册内容排版的页面背景层"
},
{
"file": "pages/auth/a04-register.vue",
"selector": ".register-submit__skin",
"reason": "依附注册按钮且不承担文字排版的卷轴皮肤"
},
{
"file": "pages/auth/a05-reset-password.vue",
"selector": ".back-button",
"reason": "认证全屏页左上角返回操作"
},
{
"file": "pages/auth/a05-reset-password.vue",
"selector": ".brand-seal",
"reason": "依附重置密码品牌标题的印章装饰"
},
{
"file": "pages/auth/a05-reset-password.vue",
"selector": ".feedback-toast",
"reason": "重置密码页面跨内容轻提示"
},
{
"file": "pages/auth/a05-reset-password.vue",
"selector": ".page-backdrop",
"reason": "不参与重置密码内容排版的页面背景层"
},
{
"file": "pages/auth/a05-reset-password.vue",
"selector": ".reset-submit__skin, .success-action__skin",
"reason": "依附重置与成功操作按钮的卷轴皮肤"
},
{
"file": "pages/auth/a05-reset-password.vue",
"selector": ".success-layer",
"reason": "用户触发的重置成功弹窗遮罩"
},
{
"file": "pages/auth/a05-reset-password.vue",
"selector": ".success-dialog__skin",
"reason": "重置成功弹窗内部装饰框层"
},
{
"file": "pages/auth/a06-auth-status.vue",
"selector": ".back-button",
"reason": "认证全屏页左上角返回操作"
},
{
"file": "pages/auth/a06-auth-status.vue",
"selector": ".brand-seal",
"reason": "依附认证状态品牌标题的印章装饰"
},
{
"file": "pages/auth/a06-auth-status.vue",
"selector": ".page-backdrop",
"reason": "不参与认证状态内容排版的页面背景层"
},
{
"file": "pages/auth/a06-auth-status.vue",
"selector": ".status-primary__skin, .recovery-action__skin",
"reason": "依附认证状态操作按钮的卷轴皮肤"
},
{
"file": "pages/auth/a06-auth-status.vue",
"selector": ".recovery-layer",
"reason": "用户触发的账号恢复弹窗遮罩"
},
{
"file": "pages/auth/a06-auth-status.vue",
"selector": ".recovery-dialog__skin",
"reason": "账号恢复弹窗内部装饰框层"
},
{
"file": "pages/genealogy/g03-create-genealogy.vue",
"selector": ".create-flow-panel__skin",
"reason": "依附创建家谱流程面板的装饰框层"
},
{
"file": "pages/genealogy/g03-create-genealogy.vue",
"selector": ".flow-header__skin",
"reason": "依附创建流程标题区的装饰皮肤"
},
{
"file": "pages/genealogy/g03-create-genealogy.vue",
"selector": ".flow-primary-action__skin",
"reason": "依附创建流程主操作的卷轴皮肤"
},
{
"file": "pages/genealogy/g03-create-genealogy.vue",
"selector": ".duplicate-reminder-layer, .flow-success-layer",
"reason": "用户触发的重复提醒与成功弹窗遮罩"
},
{
"file": "pages/genealogy/g03-create-genealogy.vue",
"selector": ".duplicate-reminder__skin, .flow-success-dialog__skin",
"reason": "创建流程弹窗内部装饰框层"
},
{
"file": "pages/genealogy/g03-create-genealogy.vue",
"selector": ".duplicate-reminder__search image, .duplicate-reminder__confirm image, .flow-success-dialog__action image",
"reason": "创建流程弹窗操作按钮的卷轴皮肤"
}
]
+34
View File
@@ -0,0 +1,34 @@
$ErrorActionPreference = 'Stop'
$root = Split-Path $PSScriptRoot -Parent
$allowlistPath = Join-Path $PSScriptRoot 'document-flow-position-allowlist.json'
$allowlist = Get-Content -LiteralPath $allowlistPath -Raw -Encoding UTF8 | ConvertFrom-Json
$allowed = @{}
foreach ($entry in $allowlist) {
$allowed["$($entry.file)::$($entry.selector)"] = $entry.reason
}
$violations = New-Object System.Collections.Generic.List[string]
$files = Get-ChildItem -LiteralPath (Join-Path $root 'pages'), (Join-Path $root 'components') -Recurse -File -Filter '*.vue'
foreach ($file in $files) {
$relative = $file.FullName.Substring($root.Length + 1).Replace('\', '/')
$source = [System.IO.File]::ReadAllText($file.FullName, [System.Text.Encoding]::UTF8)
foreach ($styleMatch in [regex]::Matches($source, '(?s)<style\b[^>]*>(?<css>.*?)</style>')) {
$css = $styleMatch.Groups['css'].Value
foreach ($rule in [regex]::Matches($css, '(?s)(?<selector>[^{}]+)\{(?<body>[^{}]*)\}')) {
$body = $rule.Groups['body'].Value
if ($body -notmatch '(?m)position\s*:\s*(absolute|fixed|sticky)\s*;') { continue }
$selector = (($rule.Groups['selector'].Value -replace '(?s)^.*@media[^\{]*', '') -replace '\s+', ' ').Trim()
$key = "$relative::$selector"
if (-not $allowed.ContainsKey($key)) {
$position = [regex]::Match($body, '(?m)position\s*:\s*(absolute|fixed|sticky)\s*;').Groups[1].Value
$violations.Add("$relative :: $selector :: position:$position")
}
}
}
}
if ($violations.Count -gt 0) {
$violations | Sort-Object | ForEach-Object { Write-Output $_ }
throw "DOCUMENT-FLOW-POSITION-CONTRACT found $($violations.Count) non-overlay positioning rules."
}
Write-Output 'DOCUMENT-FLOW-POSITION-CONTRACT PASS'
+15
View File
@@ -0,0 +1,15 @@
$ErrorActionPreference = 'Stop'
$root = Split-Path $PSScriptRoot -Parent
$page = [System.IO.File]::ReadAllText((Join-Path $root 'pages/family/f01-family-feed.vue'), [System.Text.Encoding]::UTF8)
foreach ($expected in @('box-sizing: border-box','pointer-events: none')) {
if (-not $page.Contains($expected)) { throw "F01 document-flow contract missing: $expected" }
}
foreach ($selector in @('.feed-card__copy','.feed-state-card > view')) {
$escaped = [regex]::Escape($selector)
if ($page -match "(?s)$escaped\s*\{[^}]*position\s*:\s*(absolute|fixed|sticky)\s*;") {
throw "F01 normal content selector uses positioning: $selector"
}
}
Write-Output 'F01-DOCUMENT-FLOW-CONTRACT PASS'
+43
View File
@@ -0,0 +1,43 @@
$ErrorActionPreference = 'Stop'
function Read-Utf8([string]$Path) {
[System.IO.File]::ReadAllText(
(Join-Path (Join-Path $PSScriptRoot '..') $Path),
[System.Text.Encoding]::UTF8
)
}
function Assert-Contains {
param([string]$Content, [string]$Expected, [string]$Message)
if (-not $Content.Contains($Expected)) { throw $Message }
}
function Decode-Utf8Base64([string]$Value) {
[System.Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($Value))
}
$catalog = Read-Utf8 'data/page-catalog.js'
$module = Read-Utf8 'components/ModulePage.vue'
foreach ($expected in @(
'states: {',
"eyebrow: '$(Decode-Utf8Base64 '6LCx5paH5bey5aSx5pWI')'",
"title: '$(Decode-Utf8Base64 '6L+Z56+H6LCx5paH5bey5peg5rOV5p+l55yL')'",
"copy: '$(Decode-Utf8Base64 '5YaF5a655Y+v6IO95bey6KKr5L2c6ICF5Yig6Zmk5oiW5Y+W5raI5YWs5byA77yM6K+36L+U5Zue6LCx5paH5YiX6KGo5p+l55yL5YW25LuW5YaF5a6544CC')'",
"action: '$(Decode-Utf8Base64 '6L+U5Zue6LCx5paH5YiX6KGo')'",
"actionMode: 'back'"
)) {
Assert-Contains $catalog $expected "F05 expired-state catalog contract missing: $expected"
}
foreach ($expected in @(
'page.value.states?.[moduleState.value]',
'@click="handleStateAction"',
'const handleStateAction = () => {',
'stateCopy.value.actionMode === "back"',
'uni.navigateBack()'
)) {
Assert-Contains $module $expected "ModulePage state override contract missing: $expected"
}
Write-Output 'F05-EXPIRED-STATE-CONTRACT PASS'
+58
View File
@@ -0,0 +1,58 @@
$ErrorActionPreference = 'Stop'
function Read-Utf8([string]$Path) {
[System.IO.File]::ReadAllText(
(Join-Path (Join-Path $PSScriptRoot '..') $Path),
[System.Text.Encoding]::UTF8
)
}
function Assert-Contains {
param([string]$Content, [string]$Expected, [string]$Message)
if (-not $Content.Contains($Expected)) { throw $Message }
}
$page = Read-Utf8 'pages/family/f06-article-editor.vue'
$catalog = Read-Utf8 'data/page-catalog.js'
foreach ($expected in @(
'class="article-editor-page"',
'article-editor-state--${editorState}',
'<textarea',
'v-model="form.title"',
'v-model="form.category"',
'v-model="form.content"',
'fieldErrors.title',
'fieldErrors.category',
'fieldErrors.content',
'const editorState = ref("form");',
'const allowedStates = new Set([',
'"draft"',
'"loading"',
'"validation"',
'"saving"',
'"error"',
'"success"',
'if (editorState.value === "saving") return;',
'editorState.value = "saving";',
'? "error" : "success"',
':disabled="editorState === ''saving''"',
'onUnload(() => {',
'ModulePageBackground',
'AppLoading',
'AppButton',
'border-image-source: url("/static/assets/modules/family/transparent/module-content-frame.png");',
'border-image-slice: 72 fill;',
'border-image-width: 64rpx;',
'border-image-repeat: stretch;'
)) {
Assert-Contains $page $expected "F06 dedicated editor contract missing: $expected"
}
foreach ($forbidden in @('<template><ModulePage', 'import ModulePage from', 'page-id="f06"', '@/utils/api.js', 'uni.showToast', 'uni.showModal', 'class="editor-panel__skin"')) {
if ($page.Contains($forbidden)) { throw "F06 retains forbidden dependency: $forbidden" }
}
if ($catalog -match '(?m)^\s*f06\s*:') { throw 'F06 obsolete page-catalog owner must be removed' }
Write-Output 'F06-ARTICLE-EDITOR-CONTRACT PASS'
+30
View File
@@ -0,0 +1,30 @@
$ErrorActionPreference = 'Stop'
$root = Join-Path $PSScriptRoot '..'
$files = @(
'static/assets/modules/family/f08/f08-reunion-hero.png',
'static/assets/modules/family/f08/f08-family-portrait.png',
'static/assets/modules/family/f08/f08-reunion-table.png',
'static/assets/modules/family/f08/f08-ancestral-home.png',
'static/assets/modules/family/f08/f08-ancestral-portrait.png'
)
Add-Type -AssemblyName System.Drawing
foreach ($file in $files) {
$path = Join-Path $root $file
if (-not (Test-Path -LiteralPath $path)) {
throw "Missing F08 album asset: $file"
}
$image = [System.Drawing.Image]::FromFile($path)
try {
if ($image.Width -lt 960 -or $image.Height -lt 720) {
throw "F08 album asset too small: $file $($image.Width)x$($image.Height)"
}
} finally {
$image.Dispose()
}
}
Write-Output 'F08-ALBUM-ASSETS-CONTRACT PASS'
+59
View File
@@ -0,0 +1,59 @@
$ErrorActionPreference = 'Stop'
function Read-Utf8([string]$Path) {
[System.IO.File]::ReadAllText(
(Join-Path (Join-Path $PSScriptRoot '..') $Path),
[System.Text.Encoding]::UTF8
)
}
function Assert-Contains {
param([string]$Content, [string]$Expected, [string]$Message)
if (-not $Content.Contains($Expected)) { throw $Message }
}
$page = Read-Utf8 'pages/family/f08-album-detail.vue'
$catalog = Read-Utf8 'data/page-catalog.js'
foreach ($expected in @(
'class="album-detail-page"',
'album-state--${albumState}',
'class="album-hero-photo"',
'class="album-photo-grid"',
'class="album-preview"',
'class="album-upload-action"',
'const albumState = ref("normal");',
'const photos = [',
'const openPreview = (index) =>',
'const closePreview = () =>',
'const showUploadNotice = () =>',
'query.state === "empty"',
'query.state === "preview"',
'query.state === "expired"',
'class="album-empty-state"',
'class="album-expired-state"',
'class="album-preview__position"',
':message="uploadNoticeMessage"',
'f08-reunion-hero.png',
'f08-family-portrait.png',
'f08-reunion-table.png',
'f08-ancestral-home.png',
'f08-ancestral-portrait.png',
':alt="photo.alt"',
'ModulePageBackground',
'PageHeader',
'AppButton',
'AppToast'
)) {
Assert-Contains $page $expected "F08 contract missing: $expected"
}
foreach ($forbidden in @('<ModulePage page-id="f08"', 'import ModulePage from', 'uni.showToast', 'uni.showModal')) {
if ($page.Contains($forbidden)) { throw "F08 retains forbidden dependency: $forbidden" }
}
if ($catalog -match '(?m)^\s*f08\s*:') {
throw 'F08 obsolete page-catalog owner must be removed'
}
Write-Output 'F08-ALBUM-DETAIL-CONTRACT PASS'
+94
View File
@@ -0,0 +1,94 @@
const assert = require('assert')
const origin = process.argv[2] || 'http://localhost:5173'
const sleep = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds))
const connect = async () => {
const pages = await (await fetch('http://127.0.0.1:9222/json/list')).json()
const page = pages.find((item) => item.type === 'page' && item.url.startsWith(`${origin}/`))
if (!page) throw new Error(`Chrome debugging has no ${origin} project page`)
const socket = new WebSocket(page.webSocketDebuggerUrl)
await new Promise((resolve, reject) => {
socket.addEventListener('open', resolve, { once: true })
socket.addEventListener('error', reject, { once: true })
})
let id = 0
const pending = new Map()
socket.addEventListener('message', (event) => {
const message = JSON.parse(event.data)
const request = pending.get(message.id)
if (!request) return
pending.delete(message.id)
message.error ? request.reject(new Error(message.error.message)) : request.resolve(message.result)
})
const send = (method, params = {}) => new Promise((resolve, reject) => {
id += 1
pending.set(id, { resolve, reject })
socket.send(JSON.stringify({ id, method, params }))
})
return { socket, send }
}
const valueOf = async (send, expression) => (
await send('Runtime.evaluate', { expression, returnByValue: true })
).result?.value
const waitFor = async (send, expression, message) => {
for (let attempt = 0; attempt < 50; attempt += 1) {
if (await valueOf(send, expression)) return
await sleep(80)
}
throw new Error(message)
}
const run = async () => {
const { socket, send } = await connect()
let auditId = 0
const navigate = async (route, selector) => {
auditId += 1
const url = `${origin}/?f08RuntimeAudit=${auditId}#${route}`
await send('Page.navigate', { url })
await waitFor(send, `location.href === ${JSON.stringify(url)}`, `F08 navigation failed: ${route}`)
await waitFor(send, `Boolean(document.querySelector(${JSON.stringify(selector)}))`, `F08 state missing: ${selector}`)
}
try {
await send('Page.enable')
await send('Runtime.enable')
await navigate('/pages/family/f08-album-detail', '.album-state--normal')
const photoCount = await valueOf(send, "document.querySelectorAll('.album-photo-tile').length")
assert.strictEqual(photoCount, 5, 'F08 normal state must render five photo tiles')
await send('Runtime.evaluate', { expression: "document.querySelector('.album-photo-tile')?.click()" })
await waitFor(send, "Boolean(document.querySelector('.album-preview'))", 'F08 photo click did not open preview')
await send('Runtime.evaluate', { expression: "document.querySelector('.album-preview__close')?.click()" })
await waitFor(send, "!document.querySelector('.album-preview')", 'F08 preview did not close')
await send('Runtime.evaluate', { expression: "document.querySelector('.album-upload-action')?.click()" })
await waitFor(send, "Boolean(document.querySelector('.app-toast'))", 'F08 upload notice did not render')
await navigate('/pages/family/f08-album-detail?state=empty', '.album-state--empty')
assert.strictEqual(await valueOf(send, "document.querySelectorAll('.album-photo-tile').length"), 0, 'F08 empty state must not render photos')
await navigate('/pages/family/f08-album-detail?state=preview', '.album-state--preview')
await waitFor(send, "Boolean(document.querySelector('.album-preview'))", 'F08 direct preview state did not render')
await navigate('/pages/family/f08-album-detail?state=expired', '.album-state--expired')
assert.strictEqual(await valueOf(send, "document.querySelectorAll('.album-photo-tile').length"), 0, 'F08 expired state must not render photos')
process.stdout.write('F08-ALBUM-DETAIL-RUNTIME-SMOKE PASS\n')
} finally {
socket.close()
}
}
run().catch((error) => {
process.stderr.write(`${error.stack || error.message}\n`)
process.exit(1)
})
+70
View File
@@ -0,0 +1,70 @@
$ErrorActionPreference = 'Stop'
function Read-Utf8([string]$Path) {
[System.IO.File]::ReadAllText(
(Join-Path (Join-Path $PSScriptRoot '..') $Path),
[System.Text.Encoding]::UTF8
)
}
function Assert-Contains {
param([string]$Content, [string]$Expected, [string]$Message)
if (-not $Content.Contains($Expected)) { throw $Message }
}
$page = Read-Utf8 'pages/family/f09-media-upload.vue'
$catalog = Read-Utf8 'data/page-catalog.js'
foreach ($expected in @(
'class="media-upload-page"',
'media-upload-state--${uploadState}',
'class="media-album-card"',
'class="media-photo-grid"',
'class="media-add-tile"',
'class="media-batch-field"',
'class="media-photo-field"',
'class="media-photo-editor"',
'class="media-photo-editor__thumb"',
'class="media-photo-editor__position"',
'const MAX_PHOTOS = 9;',
'const selectMockPhotos = () =>',
'const selectPhoto = (index) =>',
'const removePhoto = (index) =>',
'const addMockPhoto = () =>',
'const updateActiveNote = (event) =>',
'const startUpload = () =>',
'const retryFailed = () =>',
'const returnToAlbum = () =>',
'["permission", "selected", "uploading", "error", "success"].includes(query.state)',
'class="media-permission-action"',
'class="media-photo-status media-photo-status--error"',
'class="media-success-title"',
'class="media-progress-copy"',
'class="media-primary-action"',
'class="media-retry-action"',
'f08-reunion-hero.png',
'f08-family-portrait.png',
'f08-reunion-table.png',
'f08-ancestral-home.png',
'f08-ancestral-portrait.png',
'ModulePageBackground',
'PageHeader',
'AppButton'
)) {
Assert-Contains $page $expected "F09 contract missing: $expected"
}
foreach ($forbidden in @(
'<ModulePage page-id="f09"',
'import ModulePage from',
'uni.chooseMedia',
'uni.showModal'
)) {
if ($page.Contains($forbidden)) { throw "F09 retains forbidden dependency: $forbidden" }
}
if ($catalog -match '(?m)^\s*f09\s*:') {
throw 'F09 obsolete page-catalog owner must be removed'
}
Write-Output 'F09-MEDIA-UPLOAD-CONTRACT PASS'
+78
View File
@@ -0,0 +1,78 @@
const assert = require('assert')
const origin = process.argv[2] || 'http://localhost:5173'
const sleep = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds))
const run = async () => {
const pages = await (await fetch('http://127.0.0.1:9222/json/list')).json()
const page = pages.find((item) => item.type === 'page' && item.url.startsWith(`${origin}/`))
if (!page) throw new Error(`Chrome debugging has no ${origin} project page`)
const socket = new WebSocket(page.webSocketDebuggerUrl)
await new Promise((resolve, reject) => {
socket.addEventListener('open', resolve, { once: true })
socket.addEventListener('error', reject, { once: true })
})
let id = 0
const pending = new Map()
socket.addEventListener('message', (event) => {
const message = JSON.parse(event.data)
const request = pending.get(message.id)
if (!request) return
pending.delete(message.id)
message.error ? request.reject(new Error(message.error.message)) : request.resolve(message.result)
})
const send = (method, params = {}) => new Promise((resolve, reject) => {
id += 1
pending.set(id, { resolve, reject })
socket.send(JSON.stringify({ id, method, params }))
})
const valueOf = async (expression) => (
await send('Runtime.evaluate', { expression, returnByValue: true })
).result?.value
const waitFor = async (expression, message) => {
for (let attempt = 0; attempt < 50; attempt += 1) {
if (await valueOf(expression)) return
await sleep(80)
}
throw new Error(message)
}
try {
await send('Page.enable')
await send('Runtime.enable')
await send('Emulation.setDeviceMetricsOverride', {
width: 412,
height: 915,
deviceScaleFactor: 1,
mobile: true,
screenWidth: 412,
screenHeight: 915,
})
const url = `${origin}/?f09LayoutAudit=1#/pages/family/f09-media-upload?state=selected`
await send('Page.navigate', { url })
await waitFor("Boolean(document.querySelector('.media-upload-state--selected'))", 'F09 selected state missing')
const metrics = await valueOf(`(() => {
const textareas = Array.from(document.querySelectorAll('.media-description-panel textarea'))
const action = document.querySelector('.media-primary-action')
return {
textareaHeights: textareas.map((node) => node.getBoundingClientRect().height),
actionBottom: action.getBoundingClientRect().bottom,
viewportHeight: innerHeight,
}
})()`)
process.stdout.write(`${JSON.stringify(metrics)}\n`)
assert(metrics.textareaHeights.every((height) => height <= 72), 'F09 textarea uses an oversized browser default height')
assert(metrics.actionBottom <= metrics.viewportHeight, 'F09 selected primary action is clipped at 412x915')
process.stdout.write('F09-MEDIA-UPLOAD-LAYOUT-SMOKE PASS\n')
} finally {
try { await send('Emulation.clearDeviceMetricsOverride') } catch (_) {}
socket.close()
}
}
run().catch((error) => {
process.stderr.write(`${error.stack || error.message}\n`)
process.exit(1)
})
+120
View File
@@ -0,0 +1,120 @@
const assert = require('assert')
const origin = process.argv[2] || 'http://localhost:5173'
const sleep = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds))
const connect = async () => {
const pages = await (await fetch('http://127.0.0.1:9222/json/list')).json()
const page = pages.find((item) => item.type === 'page' && item.url.startsWith(`${origin}/`))
if (!page) throw new Error(`Chrome debugging has no ${origin} project page`)
const socket = new WebSocket(page.webSocketDebuggerUrl)
await new Promise((resolve, reject) => {
socket.addEventListener('open', resolve, { once: true })
socket.addEventListener('error', reject, { once: true })
})
let id = 0
const pending = new Map()
socket.addEventListener('message', (event) => {
const message = JSON.parse(event.data)
const request = pending.get(message.id)
if (!request) return
pending.delete(message.id)
message.error ? request.reject(new Error(message.error.message)) : request.resolve(message.result)
})
const send = (method, params = {}) => new Promise((resolve, reject) => {
id += 1
pending.set(id, { resolve, reject })
socket.send(JSON.stringify({ id, method, params }))
})
return { socket, send }
}
const valueOf = async (send, expression) => (
await send('Runtime.evaluate', { expression, returnByValue: true })
).result?.value
const waitFor = async (send, expression, message) => {
for (let attempt = 0; attempt < 50; attempt += 1) {
if (await valueOf(send, expression)) return
await sleep(80)
}
throw new Error(message)
}
const run = async () => {
const { socket, send } = await connect()
let auditId = 0
const navigate = async (route, selector) => {
auditId += 1
const url = `${origin}/?f09RuntimeAudit=${auditId}#${route}`
await send('Page.navigate', { url })
await waitFor(send, `location.href === ${JSON.stringify(url)}`, `F09 navigation failed: ${route}`)
await waitFor(send, `Boolean(document.querySelector(${JSON.stringify(selector)}))`, `F09 state missing: ${selector}`)
}
try {
await send('Page.enable')
await send('Runtime.enable')
await navigate('/pages/family/f09-media-upload', '.media-upload-state--initial')
assert.strictEqual(await valueOf(send, "document.querySelectorAll('.media-photo-tile').length"), 0)
await send('Runtime.evaluate', { expression: "document.querySelector('.media-primary-action')?.click()" })
await waitFor(send, "Boolean(document.querySelector('.media-upload-state--selected'))", 'F09 did not enter selected state')
assert.strictEqual(await valueOf(send, "document.querySelectorAll('.media-photo-tile').length"), 4)
await send('Runtime.evaluate', { expression: "document.querySelectorAll('.media-photo-tile')[1]?.click()" })
await waitFor(send, "document.querySelectorAll('.media-photo-tile')[1]?.classList.contains('media-photo-tile--active') === true", 'F09 did not switch current photo')
await waitFor(send, "document.querySelector('.media-photo-editor__position')?.textContent.includes('第 2 张 / 共 4 张') === true", 'F09 editor did not identify the second photo')
await waitFor(send, "document.querySelector('.media-photo-editor__thumb img')?.currentSrc.includes('f08-family-portrait.png') === true", 'F09 editor thumbnail did not follow the active photo')
await send('Runtime.evaluate', { expression: `(() => {
const textarea = document.querySelector('.media-photo-field textarea')
textarea.value = '院前合影'
textarea.dispatchEvent(new Event('input', { bubbles: true }))
document.querySelectorAll('.media-photo-tile')[0]?.click()
document.querySelectorAll('.media-photo-tile')[1]?.click()
})()` })
await waitFor(send, "document.querySelector('.media-photo-field textarea')?.value === '院前合影'", 'F09 per-photo note did not remain bound after switching photos')
await send('Runtime.evaluate', { expression: "document.querySelectorAll('.media-photo-remove')[1]?.click()" })
await waitFor(send, "document.querySelectorAll('.media-photo-tile').length === 3", 'F09 did not remove one photo')
await send('Runtime.evaluate', { expression: "document.querySelector('.media-primary-action')?.click()" })
await waitFor(send, "Boolean(document.querySelector('.media-field-error'))", 'F09 batch description validation did not render')
await send('Runtime.evaluate', { expression: `(() => {
const textarea = document.querySelector('.media-batch-field textarea')
textarea.value = '春节团圆照片整理'
textarea.dispatchEvent(new Event('input', { bubbles: true }))
document.querySelector('.media-primary-action')?.click()
})()` })
await waitFor(send, "Boolean(document.querySelector('.media-upload-state--uploading'))", 'F09 did not enter uploading state')
await navigate('/pages/family/f09-media-upload?state=permission', '.media-upload-state--permission')
await send('Runtime.evaluate', { expression: "document.querySelector('.media-permission-action')?.click()" })
await waitFor(send, "Boolean(document.querySelector('.media-upload-state--selected'))", 'F09 permission action did not return to selected state')
await navigate('/pages/family/f09-media-upload?state=error', '.media-upload-state--error')
assert.strictEqual(await valueOf(send, "document.querySelectorAll('.media-photo-status--error').length"), 2)
await send('Runtime.evaluate', { expression: "document.querySelector('.media-retry-action')?.click()" })
await waitFor(send, "Boolean(document.querySelector('.media-upload-state--uploading'))", 'F09 retry did not enter uploading state')
await navigate('/pages/family/f09-media-upload?state=success', '.media-upload-state--success')
await waitFor(send, "document.querySelector('.media-success-title')?.textContent.includes('4 张照片已上传') === true", 'F09 success copy missing')
process.stdout.write('F09-MEDIA-UPLOAD-RUNTIME-SMOKE PASS\n')
} finally {
socket.close()
}
}
run().catch((error) => {
process.stderr.write(`${error.stack || error.message}\n`)
process.exit(1)
})
+35
View File
@@ -0,0 +1,35 @@
$ErrorActionPreference = 'Stop'
function Read-Utf8([string]$Path) {
[System.IO.File]::ReadAllText((Join-Path (Join-Path $PSScriptRoot '..') $Path), [System.Text.Encoding]::UTF8)
}
function Assert-Contains([string]$Content, [string]$Expected, [string]$Message) {
if (-not $Content.Contains($Expected)) { throw $Message }
}
$page = Read-Utf8 'pages/family/f10-video-list.vue'
$catalog = Read-Utf8 'data/page-catalog.js'
foreach ($expected in @(
'class="video-status-page"',
'class="video-status-card"',
'class="video-return-action"',
'const returnToFamily = () =>',
'/pages/family/f01-family-feed',
'ModulePageBackground',
'PageHeader',
'AppButton'
)) {
Assert-Contains $page $expected "F10 contract missing: $expected"
}
foreach ($forbidden in @('<ModulePage page-id="f10"', 'import ModulePage from')) {
if ($page.Contains($forbidden)) { throw "F10 retains forbidden dependency: $forbidden" }
}
if ($catalog -match '(?m)^\s*f10\s*:') {
throw 'F10 obsolete page-catalog owner must be removed'
}
Write-Output 'F10-VIDEO-STATUS-CONTRACT PASS'
+77
View File
@@ -0,0 +1,77 @@
const assert = require('assert')
const origin = process.argv[2] || 'http://localhost:5173'
const sleep = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds))
const connect = async () => {
const pages = await (await fetch('http://127.0.0.1:9222/json/list')).json()
const page = pages.find((item) => item.type === 'page' && item.url.startsWith(`${origin}/`))
if (!page) throw new Error(`Chrome debugging has no ${origin} project page`)
const socket = new WebSocket(page.webSocketDebuggerUrl)
await new Promise((resolve, reject) => {
socket.addEventListener('open', resolve, { once: true })
socket.addEventListener('error', reject, { once: true })
})
let id = 0
const pending = new Map()
socket.addEventListener('message', (event) => {
const message = JSON.parse(event.data)
const request = pending.get(message.id)
if (!request) return
pending.delete(message.id)
message.error ? request.reject(new Error(message.error.message)) : request.resolve(message.result)
})
const send = (method, params = {}) => new Promise((resolve, reject) => {
id += 1
pending.set(id, { resolve, reject })
socket.send(JSON.stringify({ id, method, params }))
})
return { socket, send }
}
const valueOf = async (send, expression) => (
await send('Runtime.evaluate', { expression, returnByValue: true })
).result?.value
const waitFor = async (send, expression, message) => {
for (let attempt = 0; attempt < 50; attempt += 1) {
if (await valueOf(send, expression)) return
await sleep(80)
}
throw new Error(message)
}
const run = async () => {
const { socket, send } = await connect()
const url = `${origin}/?f10RuntimeAudit=${Date.now()}#/pages/family/f10-video-list`
try {
await send('Page.enable')
await send('Runtime.enable')
await send('Page.navigate', { url })
await waitFor(send, `location.href === ${JSON.stringify(url)}`, 'F10 navigation failed')
await waitFor(send, "Boolean(document.querySelector('.video-status-page'))", 'F10 dedicated state missing: .video-status-page')
assert.strictEqual(
await valueOf(send, "Boolean(document.querySelector('.module-state--success'))"),
false
)
await send('Runtime.evaluate', { expression: "document.querySelector('.video-return-action')?.click()" })
await waitFor(send, "location.hash.startsWith('#/pages/family/f01-family-feed')", 'F10 did not return to F01')
await waitFor(send, "Boolean(document.querySelector('.feed-state--list'))", 'F01 did not render after F10 return')
process.stdout.write('F10-VIDEO-STATUS-RUNTIME-SMOKE PASS\n')
} finally {
socket.close()
}
}
run().catch((error) => {
process.stderr.write(`${error.stack || error.message}\n`)
process.exit(1)
})
@@ -0,0 +1,18 @@
$ErrorActionPreference = 'Stop'
$root = Split-Path $PSScriptRoot -Parent
$publish = [System.IO.File]::ReadAllText((Join-Path $root 'pages/family/f02-publish-feed.vue'), [System.Text.Encoding]::UTF8)
$editor = [System.IO.File]::ReadAllText((Join-Path $root 'pages/family/f06-article-editor.vue'), [System.Text.Encoding]::UTF8)
foreach ($selector in @('.publish-form, .publish-result','.publish-field textarea')) {
$escaped = [regex]::Escape($selector)
if ($publish -match "(?s)$escaped\s*\{[^}]*position\s*:\s*(absolute|fixed|sticky)\s*;") { throw "F02 normal content selector uses positioning: $selector" }
}
foreach ($selector in @('.editor-control input, .editor-control textarea')) {
$escaped = [regex]::Escape($selector)
if ($editor -match "(?s)$escaped\s*\{[^}]*position\s*:\s*(absolute|fixed|sticky)\s*;") { throw "F06 normal content selector uses positioning: $selector" }
}
foreach ($content in @($publish,$editor)) {
if (-not $content.Contains('pointer-events: none')) { throw 'Family editor decorative skins must not intercept form controls' }
}
Write-Output 'FAMILY-EDITORS-DOCUMENT-FLOW-CONTRACT PASS'
@@ -0,0 +1,10 @@
$ErrorActionPreference = 'Stop'
$root = Split-Path $PSScriptRoot -Parent
$header = [System.IO.File]::ReadAllText((Join-Path $root 'components/PageHeader.vue'), [System.Text.Encoding]::UTF8)
$tabbar = [System.IO.File]::ReadAllText((Join-Path $root 'components/AppTabbar.vue'), [System.Text.Encoding]::UTF8)
if ($header -notmatch 'class="page-header-slot"') { throw 'PageHeader must reserve normal-flow space for the fixed header' }
if ($header -notmatch '(?s)\.page-header\s*\{[^}]*position\s*:\s*fixed\s*;[^}]*top\s*:\s*0\s*;[^}]*right\s*:\s*0\s*;[^}]*left\s*:\s*0\s*;') { throw 'PageHeader must be fixed to the top viewport edge' }
if ($tabbar -notmatch '(?s)\.app-tabbar\s*\{[^}]*position\s*:\s*fixed\s*;[^}]*right\s*:\s*0\s*;[^}]*bottom\s*:\s*0\s*;[^}]*left\s*:\s*0\s*;') { throw 'AppTabbar must remain fixed to the bottom viewport edge' }
Write-Output 'FIXED-NAVIGATION-POSITION-CONTRACT PASS'
+10
View File
@@ -0,0 +1,10 @@
$ErrorActionPreference = 'Stop'
$root = Split-Path $PSScriptRoot -Parent
$page = [System.IO.File]::ReadAllText((Join-Path $root 'pages/profile/m01-profile-home.vue'), [System.Text.Encoding]::UTF8)
foreach ($expected in @('grid-template-columns: 42rpx minmax(0, 1fr)','pointer-events: none')) {
if (-not $page.Contains($expected)) { throw "M01 document-flow contract missing: $expected" }
}
if ($page -match '(?s)\.profile-hero__label\s*\{[^}]*position\s*:\s*(absolute|fixed|sticky)\s*;') { throw 'M01 profile hero label must use the hero grid flow' }
Write-Output 'M01-DOCUMENT-FLOW-CONTRACT PASS'
@@ -0,0 +1,22 @@
$ErrorActionPreference = 'Stop'
$root = Split-Path $PSScriptRoot -Parent
$component = [System.IO.File]::ReadAllText((Join-Path $root 'components/ModulePage.vue'), [System.Text.Encoding]::UTF8)
foreach ($expected in @(
'class="form-row"',
'class="settings-row"',
'grid-template-columns: auto minmax(0, 1fr)',
'grid-template-columns: minmax(0, 1fr) auto',
'pointer-events: none'
)) {
if (-not $component.Contains($expected)) { throw "ModulePage document-flow contract missing: $expected" }
}
foreach ($selector in @('.form-row > text','.form-row input','.settings-row > text')) {
$escaped = [regex]::Escape($selector)
if ($component -match "(?s)$escaped\s*\{[^}]*position\s*:\s*(absolute|fixed|sticky)\s*;") {
throw "ModulePage normal content selector uses positioning: $selector"
}
}
Write-Output 'MODULE-PAGE-DOCUMENT-FLOW-CONTRACT PASS'
+2 -2
View File
@@ -27,13 +27,13 @@ const run = async () => {
try {
await send('Page.enable'); await send('Runtime.enable')
await open(send, '/pages/family/f04-article-list', '', '.module-page--list.module-state--ready')
await open(send, '/pages/family/f06-article-editor', '', '.module-page--form.module-state--ready')
await open(send, '/pages/records/r04-gift-editor', '', '.module-page--form.module-state--ready')
await open(send, '/pages/family/f03-feed-detail', '', '.module-page--detail.module-state--ready')
await open(send, '/pages/records/r08-growth-journal', '', '.module-page--timeline.module-state--ready')
await open(send, '/pages/profile/m03-security-settings', '', '.module-page--settings.module-state--ready')
await open(send, '/pages/family/f10-video-list', '', '.module-page--status.module-state--ready')
await open(send, '/pages/family/f04-article-list', '?state=empty', '.module-state--empty')
await open(send, '/pages/family/f06-article-editor', '?state=success', '.module-state--success')
await open(send, '/pages/records/r04-gift-editor', '?state=success', '.module-state--success')
await open(send, '/pages/profile/m03-security-settings', '?state=error', '.module-state--error')
process.stdout.write('MODULE-PAGE-RUNTIME-SMOKE PASS\n')
} finally { socket.close() }
@@ -11,11 +11,14 @@ const routes = [
['/pages/family/f01-family-feed', '.family-page'],
['/pages/family/f02-publish-feed', '.publish-page'],
['/pages/family/f04-article-list', '.module-page--list'],
['/pages/family/f06-article-editor', '.module-page--form'],
['/pages/family/f06-article-editor', '.article-editor-page'],
['/pages/family/f08-album-detail', '.album-detail-page'],
['/pages/family/f09-media-upload?state=selected', '.media-upload-page'],
['/pages/family/f03-feed-detail', '.module-page--detail'],
['/pages/records/r08-growth-journal', '.module-page--timeline'],
['/pages/profile/m03-security-settings', '.module-page--settings'],
['/pages/family/f10-video-list', '.module-page--status'],
['/pages/family/f10-video-list', '.video-status-page'],
['/pages/records/r02-person-detail?personId=1', '.person-detail-page'],
['/pages/notification/n01-message-center', '.notice-page'],
['/pages/profile/m01-profile-home', '.profile-page']
]
@@ -72,7 +75,7 @@ const run = async () => {
const metrics = await valueOf(send, `(() => {
const root = document.querySelector(${JSON.stringify(selector)})
const rect = root.getBoundingClientRect()
const visible = Array.from(document.querySelectorAll('.app-button,.list-card,.detail-card,.form-row,.settings-row,.notice-card,.profile-menu'))
const visible = Array.from(document.querySelectorAll('.app-button,.list-card,.detail-card,.form-row,.editor-control,.album-photo-tile,.album-upload-action,.media-photo-tile,.media-add-tile,.media-batch-field,.media-photo-field,.media-primary-action,.video-return-action,.person-identity-card,.person-archive-card,.person-field,.person-long-field,.person-edit-actions,.settings-row,.notice-card,.profile-menu'))
.filter((node) => node.getClientRects().length)
.map((node) => node.getBoundingClientRect())
return {
+21
View File
@@ -0,0 +1,21 @@
$ErrorActionPreference = 'Stop'
$root = Split-Path $PSScriptRoot -Parent
$page = [System.IO.File]::ReadAllText((Join-Path $root 'pages/records/r01-people-list.vue'), [System.Text.Encoding]::UTF8)
foreach ($expected in @(
'grid-template-columns: minmax(0, 1fr) 126rpx',
'pointer-events: none'
)) {
if (-not $page.Contains($expected)) { throw "R01 document-flow contract missing: $expected" }
}
foreach ($selector in @('.people-search input','.people-search__action','.person-card__copy')) {
$escaped = [regex]::Escape($selector)
if ($page -match "(?s)$escaped\s*\{[^}]*position\s*:\s*(absolute|fixed|sticky)\s*;") {
throw "R01 normal content selector uses positioning: $selector"
}
}
if ($page -notmatch '(?s)\.person-card__copy\s*\{[^}]*position\s*:\s*relative\s*;') { throw 'R01 person-card copy must stay in normal flow above its local skin' }
Write-Output 'R01-DOCUMENT-FLOW-CONTRACT PASS'
+73
View File
@@ -0,0 +1,73 @@
const assert = require('assert')
const origin = process.argv[2] || 'http://localhost:5173'
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
const run = async () => {
const pages = await (await fetch('http://127.0.0.1:9222/json/list')).json()
const page = pages.find((item) => item.type === 'page' && item.url.startsWith(`${origin}/`))
if (!page) throw new Error(`Chrome debugging has no ${origin} project page`)
const socket = new WebSocket(page.webSocketDebuggerUrl)
await new Promise((resolve, reject) => {
socket.addEventListener('open', resolve, { once: true })
socket.addEventListener('error', reject, { once: true })
})
let id = 0
const pending = new Map()
socket.addEventListener('message', (event) => {
const message = JSON.parse(event.data)
const request = pending.get(message.id)
if (!request) return
pending.delete(message.id)
message.error ? request.reject(new Error(message.error.message)) : request.resolve(message.result)
})
const send = (method, params = {}) => new Promise((resolve, reject) => {
id += 1
pending.set(id, { resolve, reject })
socket.send(JSON.stringify({ id, method, params }))
})
const valueOf = async (expression) => (await send('Runtime.evaluate', { expression, returnByValue: true })).result?.value
try {
await send('Page.enable')
await send('Runtime.enable')
await send('Emulation.setDeviceMetricsOverride', { width: 412, height: 915, deviceScaleFactor: 1, mobile: true, screenWidth: 412, screenHeight: 915 })
const url = `${origin}/?r02BackgroundAudit=1#/pages/records/r02-person-detail?personId=1&state=edit`
await send('Page.navigate', { url })
for (let attempt = 0; attempt < 60; attempt += 1) {
if (await valueOf("Boolean(document.querySelector('.person-detail-state--edit'))")) break
await sleep(80)
}
await sleep(500)
const layers = await valueOf(`Array.from(document.querySelectorAll('.person-identity-card,.person-field,.person-long-field')).map((node) => {
const skin = node.querySelector('.person-card-skin')
const image = skin?.querySelector('img')
const cardRect = node.getBoundingClientRect()
const skinRect = skin?.getBoundingClientRect()
return {
className: node.className,
hasSkin: Boolean(skin),
complete: image?.complete,
naturalWidth: image?.naturalWidth,
position: skin ? getComputedStyle(skin).position : '',
widthDelta: skinRect ? Math.abs(cardRect.width - skinRect.width) : 999,
heightDelta: skinRect ? Math.abs(cardRect.height - skinRect.height) : 999
}
})`)
assert(layers.length >= 6, 'R02 edit cards were not rendered')
for (const layer of layers) {
assert.strictEqual(layer.hasSkin, true, `${layer.className} decorative skin is missing`)
assert.strictEqual(layer.complete, true, `${layer.className} decorative skin did not load`)
assert(layer.naturalWidth > 0, `${layer.className} decorative skin has no source pixels`)
assert.strictEqual(layer.position, 'absolute', `${layer.className} decorative skin is not a local absolute layer`)
assert(layer.widthDelta <= 1 && layer.heightDelta <= 1, `${layer.className} decorative skin does not cover its card`)
}
process.stdout.write(`${JSON.stringify(layers)}\nR02-BACKGROUND-RUNTIME-SMOKE PASS\n`)
} finally {
try { await send('Emulation.clearDeviceMetricsOverride') } catch (_) {}
socket.close()
}
}
run().catch((error) => {
process.stderr.write(`${error.stack || error.message}\n`)
process.exit(1)
})
+20
View File
@@ -0,0 +1,20 @@
$ErrorActionPreference = 'Stop'
function Read-Utf8([string]$Path) { [System.IO.File]::ReadAllText((Join-Path (Join-Path $PSScriptRoot '..') $Path), [System.Text.Encoding]::UTF8) }
function Assert-Contains([string]$Content,[string]$Expected) { if (-not $Content.Contains($Expected)) { throw "R02 contract missing: $Expected" } }
$page = Read-Utf8 'pages/records/r02-person-detail.vue'
$catalog = Read-Utf8 'data/page-catalog.js'
foreach ($expected in @(
'class="person-detail-page"','person-detail-state--','class="person-identity-card"',
'class="person-edit-action"','class="person-save-action"','class="person-cancel-action"',
'shortFields','longFields','person-field__label','person-long-field__label','const enterEdit = () =>','const savePerson = () =>',
'const returnToPeople = () =>','r01-person-name-card.png','module-content-frame.png','module-field-frame.png',
'class="person-card-skin"','pointer-events: none','ModulePageBackground','PageHeader','AppButton','AppLoading','AppToast'
)) { Assert-Contains $page $expected }
foreach ($forbidden in @('<ModulePage page-id="r02"','import ModulePage from')) { if ($page.Contains($forbidden)) { throw "R02 retains forbidden dependency: $forbidden" } }
if ($catalog -match '(?m)^\s*r02\s*:') { throw 'R02 obsolete page-catalog owner must be removed' }
if ($page -notmatch '(?s)\.person-card-skin\s*\{[^}]*position\s*:\s*absolute\s*;') { throw 'R02 decorative skin must use a local absolute layer' }
foreach ($selector in @('.person-identity-card__copy','.person-field__label','.person-field input','.person-field-error','.person-long-field__label','.person-long-field textarea','.person-state-action')) {
$escaped = [regex]::Escape($selector)
if ($page -match "(?s)$escaped\s*\{[^}]*position\s*:\s*(absolute|fixed|sticky)\s*;") { throw "R02 normal content selector uses positioning: $selector" }
}
Write-Output 'R02-PERSON-DETAIL-CONTRACT PASS'
+54
View File
@@ -0,0 +1,54 @@
const assert = require('assert')
const origin = process.argv[2] || 'http://localhost:5173'
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
const connect = async () => {
const pages = await (await fetch('http://127.0.0.1:9222/json/list')).json()
const page = pages.find((item) => item.type === 'page' && item.url.startsWith(`${origin}/`))
if (!page) throw new Error(`Chrome debugging has no ${origin} project page`)
const socket = new WebSocket(page.webSocketDebuggerUrl)
await new Promise((resolve, reject) => { socket.addEventListener('open', resolve, { once: true }); socket.addEventListener('error', reject, { once: true }) })
let id = 0
const pending = new Map()
socket.addEventListener('message', (event) => { const message = JSON.parse(event.data); const request = pending.get(message.id); if (!request) return; pending.delete(message.id); message.error ? request.reject(new Error(message.error.message)) : request.resolve(message.result) })
const send = (method, params = {}) => new Promise((resolve, reject) => { id += 1; pending.set(id, { resolve, reject }); socket.send(JSON.stringify({ id, method, params })) })
return { socket, send }
}
const valueOf = async (send, expression) => (await send('Runtime.evaluate', { expression, returnByValue: true })).result?.value
const waitFor = async (send, expression, message) => { for (let attempt = 0; attempt < 60; attempt += 1) { if (await valueOf(send, expression)) return; await sleep(80) } throw new Error(message) }
const run = async () => {
const { socket, send } = await connect()
let auditId = 0
const navigate = async (route, selector) => {
auditId += 1
const url = `${origin}/?r02RuntimeAudit=${auditId}#${route}`
await send('Page.navigate', { url })
await waitFor(send, `location.href === ${JSON.stringify(url)}`, `R02 navigation failed: ${route}`)
await waitFor(send, `Boolean(document.querySelector(${JSON.stringify(selector)}))`, `R02 state missing: ${selector}`)
}
try {
await send('Page.enable'); await send('Runtime.enable')
await navigate('/pages/records/r01-people-list', '.people-state--ready')
await send('Runtime.evaluate', { expression: "document.querySelector('.person-card')?.click()" })
await waitFor(send, "location.hash.startsWith('#/pages/records/r02-person-detail?personId=1')", 'R01 did not open R02')
await waitFor(send, "Boolean(document.querySelector('.person-detail-state--detail'))", 'R02 detail did not render from R01')
assert.strictEqual(await valueOf(send, "document.querySelector('.person-identity-card')?.textContent.includes('汤文正')"), true)
assert.strictEqual(await valueOf(send, "document.querySelector('.person-identity-card')?.textContent.includes('第 18 世')"), true)
await send('Runtime.evaluate', { expression: "document.querySelector('.person-edit-action')?.click()" })
await waitFor(send, "Boolean(document.querySelector('.person-detail-state--edit'))", 'R02 did not enter edit')
await send('Runtime.evaluate', { expression: `(() => { const input=document.querySelector('.person-field input'); input.value=''; input.dispatchEvent(new Event('input',{bubbles:true})); document.querySelector('.person-save-action')?.click() })()` })
await waitFor(send, "Boolean(document.querySelector('.person-field-error'))", 'R02 validation did not render')
for (const [index, value] of ['汤文正', '家谱管理员', '18'].entries()) {
await send('Runtime.evaluate', { expression: `(() => { const input=document.querySelectorAll('.person-field input')[${index}]; const setter=Object.getOwnPropertyDescriptor(HTMLInputElement.prototype,'value').set; setter.call(input,${JSON.stringify(value)}); input.dispatchEvent(new Event('input',{bubbles:true})) })()` })
await waitFor(send, `document.querySelectorAll('.person-field input')[${index}]?.value === ${JSON.stringify(value)}`, `R02 edit field ${index} did not settle`)
await sleep(150)
}
await send('Runtime.evaluate', { expression: "document.querySelector('.person-save-action')?.click()" })
await waitFor(send, "Boolean(document.querySelector('.person-detail-state--detail'))", 'R02 did not return to detail after save')
await waitFor(send, "Boolean(document.querySelector('.app-toast'))", 'R02 save feedback missing')
await navigate('/pages/records/r02-person-detail?personId=1&state=privacy', '.person-detail-state--privacy')
await navigate('/pages/records/r02-person-detail?personId=1&state=expired', '.person-detail-state--expired')
await navigate('/pages/records/r02-person-detail?personId=1&state=error', '.person-detail-state--error')
process.stdout.write('R02-PERSON-DETAIL-RUNTIME-SMOKE PASS\n')
} finally { socket.close() }
}
run().catch((error) => { process.stderr.write(`${error.stack || error.message}\n`); process.exit(1) })
+19 -4
View File
@@ -3,18 +3,29 @@ $root = Split-Path -Parent $PSScriptRoot
$page = Get-Content -LiteralPath (Join-Path $root 'pages/tree/t01-tree-overview.vue') -Raw -Encoding utf8
foreach ($required in @(
'a01-primary-button-v2.png',
'a01-secondary-button-v2.png',
'import AppButton from "@/components/AppButton.vue"',
'<AppButton',
'type="secondary"',
'@click="toMember"',
'@click="toAddRelative"',
't01-member-node-standard.png',
't01-member-node-selected.png',
't01-state-panel.png',
't01-member-drawer.png',
'lineage-connector--root',
'lineage-pan-cue'
'lineage-pan-cue',
'const initialScrollLeft = ref(90)'
)) {
if ($page -notmatch [regex]::Escape($required)) { throw "T01 member action asset missing: $required" }
}
foreach ($forbidden in @(
'a01-primary-button-v2.png',
'a01-secondary-button-v2.png'
)) {
if ($page -match [regex]::Escape($forbidden)) { throw "T01 must consume AppButton instead of legacy action asset: $forbidden" }
}
foreach ($rule in @(
'(?s)\.tree-toolbar__title text:last-child\s*\{[^}]*font-size:\s*22rpx;',
'(?s)\.tree-toolbar__actions\s*\{[^}]*font-size:\s*23rpx;',
@@ -23,7 +34,11 @@ foreach ($rule in @(
'(?s)\.node-years\s*\{[^}]*font-size:\s*20rpx;',
'(?s)\.tree-state-card__copy\s*\{[^}]*font-size:\s*24rpx;',
'(?s)\.sheet-meta\s*\{[^}]*font-size:\s*22rpx;',
'(?s)\.sheet-action text\s*\{[^}]*font-family:\s*''Microsoft YaHei''[^}]*font-size:\s*22rpx;'
'(?s)\.generation-rail\s*\{[^}]*width:\s*190rpx;',
'(?s)\.tree-scroll--lineage\s*\{[^}]*width:\s*calc\(100% - 190rpx\);[^}]*margin-left:\s*190rpx;',
'(?s)\.member-sheet\s*\{[^}]*height:\s*240rpx;',
'(?s)\.sheet-actions\s*\{[^}]*right:\s*0;[^}]*bottom:\s*24rpx;[^}]*left:\s*0;[^}]*justify-content:\s*center;',
'(?s)\.sheet-action\s*\{[^}]*width:\s*250rpx;[^}]*min-height:\s*88rpx;'
)) {
if ($page -notmatch $rule) { throw "T01 readable visual rule missing: $rule" }
}
@@ -0,0 +1,9 @@
$ErrorActionPreference = 'Stop'
$root = Split-Path $PSScriptRoot -Parent
$page = [System.IO.File]::ReadAllText((Join-Path $root 'pages/tree/t01-tree-overview.vue'), [System.Text.Encoding]::UTF8)
foreach ($selector in @('.tree-state-card','.member-sheet__copy','.sheet-actions')) {
$escaped = [regex]::Escape($selector)
if ($page -match "(?s)$escaped\s*\{[^}]*position\s*:\s*(absolute|fixed|sticky)\s*;") { throw "T01 normal sheet/state content uses positioning: $selector" }
}
if (-not $page.Contains('margin-top: auto')) { throw 'T01 fixed member sheet actions must use flex flow' }
Write-Output 'T01-SHEET-STATE-DOCUMENT-FLOW-CONTRACT PASS'
+12 -5
View File
@@ -27,16 +27,20 @@ if ($catalog -match 't02:') { throw 'T02 duplicate catalog entry must be removed
if ($page -match "@/utils/api\.js|\bappApi\b") { throw 'T01 design phase must not connect the API layer' }
foreach ($required in @(
"const treeState = ref('loading')",
'const treeState = ref("loading")',
'tree-state--tree',
'tree-state--landscape',
'tree-state--empty',
'tree-state--error',
'tree-canvas--state',
'lineage-connector',
'generation-band--twelve',
'generation-band--thirteen',
'generation-band--fourteen',
'const generationRows = [',
'v-for="row in generationRows"',
':style="generationRowStyle(row)"',
'class="tree-stage"',
'class="generation-rail"',
'tree-scroll--lineage',
'const generationRowStyle = (row)',
't01-member-node-standard.png',
't01-member-node-selected.png',
't01-state-panel.png',
@@ -51,7 +55,10 @@ foreach ($required in @(
foreach ($forbidden in @(
'g03-create-flow-panel.png',
'g06-search-input-wide.png',
'application-status-card.png'
'application-status-card.png',
'generation-band--twelve',
'generation-band--thirteen',
'generation-band--fourteen'
)) {
if ($page -match [regex]::Escape($forbidden)) { throw "T01 must not keep opaque surface asset: $forbidden" }
}
@@ -0,0 +1,20 @@
$ErrorActionPreference = 'Stop'
$root = Split-Path $PSScriptRoot -Parent
$component = [System.IO.File]::ReadAllText((Join-Path $root 'components/tree/TreeMemberForm.vue'), [System.Text.Encoding]::UTF8)
foreach ($expected in @(
'grid-template-columns: auto minmax(0, 1fr)',
'min-height: min(640px, calc((100vw - 16px) * 1.48))',
'pointer-events: none'
)) {
if (-not $component.Contains($expected)) { throw "TreeMemberForm document-flow contract missing: $expected" }
}
foreach ($selector in @('.member-form, .member-form-result','.member-field > text','.member-field input')) {
$escaped = [regex]::Escape($selector)
if ($component -match "(?s)$escaped\s*\{[^}]*position\s*:\s*(absolute|fixed|sticky)\s*;") {
throw "TreeMemberForm normal content selector uses positioning: $selector"
}
}
Write-Output 'TREE-MEMBER-FORM-DOCUMENT-FLOW-CONTRACT PASS'
-8
View File
@@ -1,8 +0,0 @@
{
"hash": "e9ef850f",
"configHash": "a875c297",
"lockfileHash": "e3b0c442",
"browserHash": "f2db9496",
"optimized": {},
"chunks": {}
}
-3
View File
@@ -1,3 +0,0 @@
{
"type": "module"
}