Compare commits

..

67 Commits

Author SHA1 Message Date
rain f53a2dbc87 修改问题 2026-09-17 16:36:05 +08:00
rain bbc024f7f2 修改完成待测试 2026-09-13 17:45:52 +08:00
rain 9ad572907b feat: 完成前端业务闭环与后端联调 2026-08-24 23:37:56 +08:00
rain c59e36f933 chore: remove unused WeChat mini program target 2026-08-12 18:26:45 +08:00
rain ab90ab488b chore: remove obsolete project documentation 2026-08-12 18:26:06 +08:00
rain 5dabcd175e chore: reduce design pipeline to runtime asset checks 2026-08-12 18:24:40 +08:00
rain 964262f693 docs: establish canonical OpenAPI contract 2026-08-12 18:24:05 +08:00
rain 8207965754 test: replace legacy suite with core project checks 2026-08-12 18:23:30 +08:00
rain cc706378c2 feat: migrate app routes and business modules 2026-08-12 18:22:59 +08:00
rain 555aa00043 feat: 完成70%全局样式与可读性优化 2026-07-31 11:27:15 +08:00
rain 96524e4d6c 1 2026-07-30 07:38:09 +08:00
rain 8c2355a52c 修改未完成 2026-07-29 18:16:08 +08:00
rain 6fbcf21024 待测试 2026-07-28 07:58:30 +08:00
rain 1e9e25fe67 修改部分样式 2026-07-27 17:30:01 +08:00
rain 04287e6777 待测试 2026-07-27 17:17:46 +08:00
rain 1eae3bbef4 完成80% 2026-07-27 06:50:23 +08:00
rain 8475bbd19a 1 2026-07-24 18:08:43 +08:00
rain ec8486b035 60% 2026-07-24 18:03:14 +08:00
rain c7f278fe79 完成70% 2026-07-24 07:56:33 +08:00
rain bb6431b319 完成40% 2026-07-23 17:21:33 +08:00
rain f1edc6b533 完成50% 2026-07-23 08:24:07 +08:00
rain 9b0ad62df4 接口开始5% 2026-07-22 17:31:38 +08:00
rain eced3d1e6e 完成全项目响应式审核与换机交接 2026-07-22 07:41:27 +08:00
rain a127280ce6 1 2026-07-21 21:02:57 +08:00
rain 5d99bae3f2 feat: unify project responsive layouts 2026-07-21 20:59:10 +08:00
rain a0d19f4aab test: tighten G03 shared header contract 2026-07-21 13:56:23 +08:00
rain 9362c47b95 refactor: reuse shared header on G03 2026-07-21 13:52:59 +08:00
rain 2f22df61b3 fix: adapt shared page header to safe area 2026-07-21 13:46:16 +08:00
rain 779a44279c docs: plan shared header safe area implementation 2026-07-21 13:34:47 +08:00
rain 0542fa9102 docs: define shared header safe area 2026-07-21 13:28:13 +08:00
rain 668b4ef55f fix: sync G01 role shortcuts 2026-07-21 13:06:36 +08:00
rain 8468cc0f89 docs: define G01 role shortcut rules 2026-07-21 12:50:38 +08:00
rain ec1295de0e 记录认证页安卓自适应验收 2026-07-21 12:15:00 +08:00
rain cd3dbb2ab1 同步封存A06自适应骨架 2026-07-21 12:13:07 +08:00
rain b774ce8212 迁移A05并恢复认证页水墨背景 2026-07-21 12:08:15 +08:00
rain 75c8e4fcbb 测试A05重设密码页自适应结构 2026-07-21 11:51:58 +08:00
rain a28678ca0b 迁移A04为自适应文档流 2026-07-21 11:49:46 +08:00
rain 5a86803f9b 测试A04注册页自适应结构 2026-07-21 11:33:48 +08:00
rain 40947a1fe5 提取认证页共享自适应骨架 2026-07-21 11:32:12 +08:00
rain 8f88d6ecdf 测试认证页共享自适应骨架 2026-07-21 11:26:59 +08:00
rain c0f3233e26 规划认证页自适应迁移 2026-07-21 11:25:57 +08:00
rain 9c7ceb9a02 记录A01验证码态与Toast验收 2026-07-21 11:23:25 +08:00
rain daccf7ba3d 记录A01安卓自适应验收 2026-07-21 11:15:13 +08:00
rain bc4061c8dc 调整A01安卓视觉比例 2026-07-21 11:13:26 +08:00
rain c5af1f2414 验证A01多视口自适应结构 2026-07-21 10:47:47 +08:00
rain 119d820308 重构A01为自适应文档流 2026-07-21 10:45:59 +08:00
rain 15cce0aba8 修正A01固定高度合同匹配 2026-07-21 10:45:59 +08:00
rain 086fc3c7e4 增加手机可用视口基础变量 2026-07-21 10:43:51 +08:00
rain 47afb100cd 测试A01自适应页面结构 2026-07-21 10:43:05 +08:00
rain 69592868d0 规划A01自适应结构证明 2026-07-21 10:37:20 +08:00
rain 2d33ee71e4 重写全项目手机自适应架构 2026-07-21 10:30:38 +08:00
rain 4b338ebefc 设计全项目手机视口自适应合同 2026-07-21 09:30:34 +08:00
rain ff60119277 修改完成 2026-07-21 07:53:08 +08:00
rain 01d246c47b 保存视觉审核与文档流迁移进度 2026-07-20 17:23:13 +08:00
rain 5a31a75da0 Review changes batch 6 of 6 2026-07-20 06:52:33 +08:00
rain db97d3da27 Review changes batch 5 of 6 2026-07-20 06:52:26 +08:00
rain 0d0645a112 Review changes batch 4 of 6 2026-07-20 06:52:24 +08:00
rain 3e3251a4fc Review changes batch 3 of 6 2026-07-20 06:52:23 +08:00
rain c848653758 Review changes batch 2 of 6 2026-07-20 06:52:23 +08:00
rain 0857e5dd13 Review changes batch 1 of 6 2026-07-20 06:52:22 +08:00
rain 887481a4ab 验收完成40% 2026-07-17 08:08:33 +08:00
rain 23cfd365a1 视觉审核完成30% 2026-07-16 18:13:03 +08:00
rain cb25317412 验收20% 2026-07-16 07:58:53 +08:00
rain 0dec90ffdd 开始审核样式 2026-07-15 17:23:33 +08:00
rain 241a7af54c 修改完成55% 2026-07-15 08:31:33 +08:00
rain 8c98936da5 完成40% 2026-07-14 17:36:43 +08:00
rain 1015e60ff7 设计完成30% 2026-07-14 07:01:45 +08:00
395 changed files with 64080 additions and 12428 deletions
+67
View File
@@ -0,0 +1,67 @@
######################################################################
# 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
# Regenerable local output and audit caches
**/node_modules/
/tmp/
/unpackage/
/.vite/
/.audit/
/design-pipeline/generated/
/tmp-g01-icon-audit.png
/tmp-mumu-current.png
/artifacts/
/docs/audit-*/
/docs/audits/
/book-style-*.png
+9
View File
@@ -0,0 +1,9 @@
{
"version" : "1.0",
"configurations" : [
{
"playground" : "standard",
"type" : "uni-app:app-android"
}
]
}
-5287
View File
File diff suppressed because it is too large Load Diff
+2 -5
View File
@@ -1,11 +1,8 @@
<script> <script>
export default { export default {}
onLaunch() {
console.log('家谱 App 已启动')
}
}
</script> </script>
<style lang="scss"> <style lang="scss">
// Global styles own the fixed-header host stacking contract.
@import './styles/global.scss'; @import './styles/global.scss';
</style> </style>
+22
View File
@@ -0,0 +1,22 @@
{
"version" : "1",
"prompt" : "template",
"title" : "服务条款和隐私政策",
"message" : "请审慎阅读并充分理解<a href=\"https://app.ddxcjp.cn/h5/#/pages/content/detail?id=62\">《用户协议》</a>和<a href=\"https://app.ddxcjp.cn/h5/#/pages/content/detail?id=63\">《隐私政策》</a>。同意后,我们将按照协议约定为你提供家谱服务。",
"buttonAccept" : "同意并继续",
"buttonRefuse" : "暂不同意",
"hrefLoader" : "default",
"backToExit" : true,
"second" : {
"title" : "确认退出",
"message" : "使用应用前需要同意<a href=\"https://app.ddxcjp.cn/h5/#/pages/content/detail?id=62\">《用户协议》</a>和<a href=\"https://app.ddxcjp.cn/h5/#/pages/content/detail?id=63\">《隐私政策》</a>。不同意将退出应用。",
"buttonAccept" : "同意并继续",
"buttonRefuse" : "退出应用"
},
"disagreeMode" : {
"support" : false,
"loadNativePlugins" : false,
"visitorEntry" : false,
"showAlways" : false
}
}
+31
View File
@@ -0,0 +1,31 @@
<template>
<image class="app-avatar" :src="avatarSource" mode="aspectFill" />
</template>
<script setup>
import { computed } from "vue";
import { getDefaultAvatar } from "@/utils/avatar.js";
const props = defineProps({
sex: {
type: [String, Number],
default: "",
},
src: {
type: String,
default: "",
},
});
const avatarSource = computed(
() => props.src.trim() || getDefaultAvatar(props.sex),
);
</script>
<style scoped>
.app-avatar {
display: block;
width: 100%;
height: 100%;
}
</style>
+117
View File
@@ -0,0 +1,117 @@
<template>
<button
class="app-button"
:class="[
`app-button--${type}`,
{
'app-button--block': block,
'app-button--compact': compact,
'app-button--disabled': disabled,
},
]"
:disabled="disabled"
:aria-label="label"
:hover-class="disabled ? 'none' : 'app-button--pressed'"
@click="handleClick"
>
<image class="app-button__skin" :src="skin" mode="aspectFit" />
<text class="app-button__label app-single-line"
><slot>{{ label }}</slot></text
>
</button>
</template>
<script setup>
import { computed } from "vue";
const props = defineProps({
label: { type: String, default: "" },
type: { type: String, default: "primary" },
block: { type: Boolean, default: false },
compact: { type: Boolean, default: false },
disabled: { type: Boolean, default: false },
});
const emit = defineEmits(["click"]);
const skin = computed(() =>
props.type === "secondary"
? "/static/assets/foundation/transparent/scroll-secondary.png"
: "/static/assets/foundation/transparent/scroll-primary.png",
);
const handleClick = (event) => {
if (!props.disabled) emit("click", event);
};
</script>
<style scoped lang="scss">
@use "../styles/adaptive-frame-profiles.scss" as adaptive;
.app-button {
display: inline-grid;
width: 420rpx;
max-width: 100%;
min-height: 88rpx;
place-items: center;
box-sizing: border-box;
margin: 0;
padding: 0;
border: 0;
background: transparent;
line-height: normal;
}
.app-button::after {
border: 0;
}
.app-button--block {
display: grid;
width: 100%;
}
.app-button--compact {
}
.app-button--compact.app-button--primary {
@include adaptive.adaptive-scroll-button(primary);
}
.app-button--compact.app-button--secondary {
@include adaptive.adaptive-scroll-button(secondary);
}
.app-button--compact .app-button__skin {
display: none;
}
.app-button--compact .app-button__label {
padding: 0;
font-size: clamp(14px, 23rpx, 17px);
letter-spacing: 0;
white-space: nowrap;
}
.app-button__skin {
width: 100%;
height: 100%;
pointer-events: none;
}
.app-button__skin,
.app-button__label {
grid-area: 1 / 1;
}
.app-button__label {
z-index: 1;
padding: 12rpx 38rpx;
box-sizing: border-box;
color: #fffaf0;
font-size: clamp(16px, 29rpx, 20px);
font-weight: 700;
letter-spacing: 3rpx;
line-height: 1.35;
text-align: center;
}
.app-button--secondary .app-button__label {
color: #5c4330;
}
.app-button--disabled {
opacity: 0.48;
}
.app-button--pressed {
opacity: 0.78;
transform: translateY(1rpx);
}
</style>
+204
View File
@@ -0,0 +1,204 @@
<template>
<view
v-if="visible"
class="app-dialog-layer"
@click="closeOnMask && cancel()"
>
<view
ref="dialogRef"
class="app-dialog"
role="dialog"
aria-modal="true"
:aria-label="title"
tabindex="-1"
@click.stop
@keydown.esc.stop.prevent="cancel"
@keydown.tab="trapFocus"
>
<scroll-view class="app-dialog__content" scroll-y>
<view class="app-dialog__copy">
<text v-if="eyebrow" class="app-dialog__eyebrow">{{ eyebrow }}</text>
<text class="app-dialog__title">{{ title }}</text>
<text v-if="message" class="app-dialog__message">{{ message }}</text>
</view>
<slot />
<view
class="app-dialog__actions"
:class="{ 'app-dialog__actions--single': !showCancel }"
>
<AppButton
v-if="showCancel"
type="secondary"
:label="cancelText"
:compact="compactActions && showCancel"
@click="cancel"
/>
<AppButton
:label="confirmText"
:compact="compactActions && showCancel"
@click="$emit('confirm')"
/>
</view>
</scroll-view>
</view>
</view>
</template>
<script setup>
import { nextTick, ref, watch } from "vue";
import AppButton from "@/components/AppButton.vue";
const props = defineProps({
visible: { type: Boolean, default: false },
eyebrow: { type: String, default: "" },
title: { type: String, required: true },
message: { type: String, default: "" },
confirmText: { type: String, default: "我知道了" },
cancelText: { type: String, default: "取消" },
showCancel: { type: Boolean, default: false },
compactActions: { type: Boolean, default: false },
closeOnMask: { type: Boolean, default: true },
});
const emit = defineEmits(["confirm", "cancel", "close"]);
const dialogRef = ref(null);
let previousFocus = null;
const getDialogElement = () => {
const target = dialogRef.value;
if (target && typeof target.querySelectorAll === "function") return target;
if (target?.$el && typeof target.$el.querySelectorAll === "function") return target.$el;
return null;
};
const trapFocus = (event) => {
if (typeof document === "undefined") return;
const dialog = getDialogElement() || event.currentTarget;
if (!dialog || typeof dialog.querySelectorAll !== "function") return;
const focusable = Array.from(
dialog.querySelectorAll(
'button:not([disabled]), [href], input:not([disabled]), textarea:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])',
),
).filter((element) => element.offsetParent !== null);
if (focusable.length === 0) {
event.preventDefault();
dialog.focus?.();
return;
}
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (event.shiftKey && (document.activeElement === first || document.activeElement === dialog)) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
}
};
watch(
() => props.visible,
async (visible) => {
if (typeof document === "undefined") return;
if (visible) {
previousFocus = document.activeElement;
await nextTick();
getDialogElement()?.focus?.();
return;
}
previousFocus?.focus?.();
previousFocus = null;
},
);
const cancel = () => {
emit("cancel");
emit("close");
};
</script>
<style scoped lang="scss">
@use "../styles/adaptive-frame-profiles.scss" as adaptive;
.app-dialog-layer {
position: fixed;
z-index: 80;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
padding: calc(40rpx + env(safe-area-inset-top)) 40rpx calc(40rpx + env(safe-area-inset-bottom));
background: rgba(35, 18, 10, 0.62);
}
.app-dialog {
display: flex;
width: 650rpx;
max-width: 100%;
max-height: calc(var(--app-viewport-height, 100vh) - 80rpx - env(safe-area-inset-top) - env(safe-area-inset-bottom));
flex-direction: column;
overflow: hidden;
@include adaptive.adaptive-auth-dialog;
}
.app-dialog__content {
z-index: 1;
display: flex;
min-height: 0;
flex: 1;
flex-direction: column;
align-items: center;
box-sizing: border-box;
padding: 16rpx 20rpx 12rpx;
text-align: center;
overflow-y: auto;
}
.app-dialog__copy {
display: flex;
width: 100%;
flex-direction: column;
align-items: center;
}
.app-dialog__title,
.app-dialog__message {
display: block;
width: 100%;
}
.app-dialog__eyebrow {
color: #9f170f;
font-size: clamp(14px, 22rpx, 17px);
font-weight: 700;
letter-spacing: 4rpx;
}
.app-dialog__title {
color: #8f160f;
font-size: clamp(22px, 42rpx, 28px);
font-weight: 700;
letter-spacing: 4rpx;
}
.app-dialog__eyebrow + .app-dialog__title {
margin-top: 10rpx;
}
.app-dialog__message {
margin-top: 22rpx;
color: #513a28;
font-size: clamp(16px, 27rpx, 20px);
line-height: 1.65;
}
.app-dialog__actions {
display: grid;
width: 100%;
grid-template-columns: 1fr 1fr;
gap: 16rpx;
margin-top: 26rpx;
}
.app-dialog__actions :deep(.app-button__label) {
padding-right: 18rpx;
padding-left: 18rpx;
}
.app-dialog__actions--single {
display: flex;
justify-content: center;
}
.app-dialog__actions .app-button {
width: 100%;
min-height: 82rpx;
}
</style>
+160
View File
@@ -0,0 +1,160 @@
<template>
<view
class="app-loading"
:class="`app-loading--${variant}`"
role="status"
aria-live="polite"
aria-busy="true"
>
<view class="app-loading__emblem" aria-hidden="true">
<image
class="app-loading__seal"
src="/static/assets/foundation/transparent/brand-seal.png"
mode="aspectFit"
/>
<image
class="app-loading__knot"
src="/static/assets/foundation/transparent/auth-divider-knot.png"
mode="aspectFit"
/>
</view>
<text class="app-loading__copy">{{ text }}</text>
<text v-if="description" class="app-loading__description">{{
description
}}</text>
</view>
</template>
<script setup>
defineProps({
variant: {
type: String,
default: "page",
validator: (value) => ["page", "section"].includes(value),
},
text: { type: String, default: "正在展开,请稍候…" },
description: { type: String, default: "" },
});
</script>
<style scoped lang="scss">
.app-loading {
z-index: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: #62584c;
text-align: center;
}
.app-loading__emblem {
display: flex;
flex-direction: column;
align-items: center;
}
.app-loading__seal {
animation: app-loading-seal-breathe 1.6s ease-in-out infinite;
}
.app-loading__knot {
margin-top: 8rpx;
opacity: 0.72;
animation: app-loading-knot-breathe 1.6s ease-in-out infinite;
}
.app-loading__copy {
font-weight: 500;
letter-spacing: 2rpx;
}
.app-loading__description {
color: #62584c;
line-height: 1.65;
}
.app-loading--page {
min-height: 320rpx;
}
.app-loading--page .app-loading__seal {
width: 132rpx;
height: 136rpx;
}
.app-loading--page .app-loading__knot {
width: 56rpx;
height: 18rpx;
}
.app-loading--page .app-loading__copy {
margin-top: 22rpx;
font-size: clamp(17px, 30rpx, 22px);
}
.app-loading--page .app-loading__description {
margin-top: 14rpx;
font-size: clamp(15px, 24rpx, 18px);
}
.app-loading--section {
min-height: 180rpx;
}
.app-loading--section .app-loading__seal {
width: 88rpx;
height: 90rpx;
}
.app-loading--section .app-loading__knot {
width: 42rpx;
height: 14rpx;
margin-top: 5rpx;
}
.app-loading--section .app-loading__copy {
margin-top: 14rpx;
font-size: clamp(15px, 24rpx, 18px);
}
.app-loading--section .app-loading__description {
margin-top: 10rpx;
font-size: clamp(14px, 22rpx, 17px);
}
@keyframes app-loading-seal-breathe {
0%,
100% {
opacity: 0.82;
transform: scale(0.96);
}
50% {
opacity: 1;
transform: scale(1);
}
}
@keyframes app-loading-knot-breathe {
0%,
100% {
opacity: 0.44;
}
50% {
opacity: 0.76;
}
}
@keyframes app-loading-seal-essential-pulse {
0%,
100% {
opacity: 0.58;
}
50% {
opacity: 1;
}
}
@keyframes app-loading-knot-essential-pulse {
0%,
100% {
opacity: 0.28;
}
50% {
opacity: 0.86;
}
}
@media (prefers-reduced-motion: reduce) {
.app-loading__seal {
animation: none;
transform: none;
}
.app-loading__knot {
animation: none;
transform: none;
}
}
</style>
+254
View File
@@ -0,0 +1,254 @@
<template>
<view
v-if="showEmpty || promotionState === 'error' || promotions.length"
class="promotion-strip"
:aria-label="title"
>
<view class="promotion-strip__heading">
<view class="promotion-strip__mark" aria-hidden="true"></view>
<text>{{ title }}</text>
</view>
<view v-if="promotionState === 'loading'" class="promotion-strip__state">
<text>正在加载广告内容</text>
</view>
<view v-else-if="promotionState === 'error'" class="promotion-strip__error">
<text>推荐内容暂时没有显示</text>
<button @click="loadPromotions">重新加载</button>
</view>
<view v-else-if="!promotions.length" class="promotion-strip__state">
<text>暂无广告内容</text>
</view>
<scroll-view v-else class="promotion-strip__scroll" scroll-x :show-scrollbar="false">
<view class="promotion-strip__list">
<view
v-for="promotion in promotions"
:key="promotion.id"
class="promotion-strip__card"
:class="{ 'promotion-strip__card--linked': promotion.targetUrl }"
:role="promotion.targetUrl ? 'button' : undefined"
:aria-label="promotion.targetUrl ? `${promotion.title}查看详情` : promotion.title"
@click="openPromotion(promotion)"
>
<image
v-if="promotion.coverFile?.accessUrl"
class="promotion-strip__cover"
:src="promotion.coverFile.accessUrl"
mode="aspectFill"
/>
<view class="promotion-strip__copy">
<text class="promotion-strip__title">{{ promotion.title }}</text>
<text v-if="promotion.description" class="promotion-strip__description">
{{ promotion.description }}
</text>
<text v-if="promotion.targetUrl" class="promotion-strip__action">
查看详情
</text>
</view>
</view>
</view>
</scroll-view>
<text
v-if="operationFeedback"
class="promotion-strip__feedback"
role="status"
>
{{ operationFeedback }}
</text>
</view>
</template>
<script setup>
import { onBeforeUnmount, onMounted, ref } from "vue";
import {
createRequestController,
isRequestCancelled
} from "@/services/api/request-controller.js";
import { siteContentApi } from "@/services/api/site-content-service.js";
import { openSiteContentTarget } from "@/utils/navigation/gateway.js";
const props = defineProps({
placement: { type: String, required: true },
title: { type: String, default: "推荐内容" },
showEmpty: { type: Boolean, default: false },
});
const promotions = ref([]);
const promotionState = ref("loading");
const operationFeedback = ref("");
const promotionListController = createRequestController();
let isMounted = true;
const loadPromotions = async () => {
promotionListController.abort();
promotionState.value = "loading";
operationFeedback.value = "";
try {
const promotionRows = await siteContentApi.getPromotions({
placement: props.placement,
requestController: promotionListController,
});
if (!isMounted) return;
promotions.value = promotionRows;
promotionState.value = "ready";
} catch (error) {
if (!isMounted || isRequestCancelled(error)) return;
promotionState.value = "error";
}
};
const openPromotion = async (promotion) => {
const targetUrl = promotion?.targetUrl || "";
if (!targetUrl) return;
const showExternalLinkError = () => {
if (!isMounted) return;
operationFeedback.value = "链接暂时打不开,请稍后再试。";
};
try {
await openSiteContentTarget(targetUrl, showExternalLinkError);
} catch {
if (!isMounted) return;
operationFeedback.value = targetUrl.startsWith("/")
? "这个页面暂时打不开,请稍后再试。"
: "链接暂时打不开,请稍后再试。";
}
};
onMounted(loadPromotions);
onBeforeUnmount(() => {
isMounted = false;
promotionListController.abort();
});
</script>
<style scoped lang="scss">
.promotion-strip {
position: relative;
z-index: 1;
margin: 20rpx 24rpx 28rpx;
padding: 22rpx 0 20rpx;
border: 1rpx solid rgba(145, 89, 36, 0.34);
border-radius: 14rpx;
background: rgba(255, 250, 239, 0.9);
box-shadow: 0 7rpx 20rpx rgba(74, 37, 18, 0.08);
}
.promotion-strip__heading {
display: flex;
align-items: center;
gap: 12rpx;
padding: 0 22rpx 18rpx;
color: #6f1a14;
font-family: STKaiti, KaiTi, serif;
font-size: clamp(16px, 27rpx, 20px);
font-weight: 700;
}
.promotion-strip__mark {
width: 7rpx;
height: 30rpx;
border-radius: 8rpx;
background: linear-gradient(#c89b4b, #8f1b14);
}
.promotion-strip__scroll {
width: 100%;
white-space: nowrap;
}
.promotion-strip__list {
display: inline-flex;
gap: 16rpx;
padding: 0 22rpx;
}
.promotion-strip__card {
display: flex;
width: 500rpx;
min-height: 142rpx;
overflow: hidden;
border: 1rpx solid rgba(125, 83, 45, 0.23);
border-radius: 12rpx;
background: #fffdf8;
white-space: normal;
}
.promotion-strip__card--linked:active {
opacity: 0.78;
}
.promotion-strip__cover {
width: 176rpx;
min-height: 142rpx;
flex: 0 0 auto;
background: #eee4d4;
}
.promotion-strip__copy {
display: flex;
min-width: 0;
flex: 1;
flex-direction: column;
padding: 18rpx 20rpx;
}
.promotion-strip__title {
overflow: hidden;
color: #402c20;
font-size: clamp(15px, 25rpx, 18px);
font-weight: 700;
text-overflow: ellipsis;
white-space: nowrap;
}
.promotion-strip__description {
display: -webkit-box;
overflow: hidden;
margin-top: 8rpx;
color: #766252;
font-size: clamp(13px, 21rpx, 16px);
line-height: 1.45;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
.promotion-strip__action {
margin-top: auto;
padding-top: 8rpx;
color: #9f170f;
font-size: clamp(13px, 21rpx, 16px);
}
.promotion-strip__error {
display: flex;
align-items: center;
justify-content: space-between;
gap: 18rpx;
padding: 0 22rpx;
color: #766252;
font-size: clamp(13px, 21rpx, 16px);
}
.promotion-strip__state {
padding: 12rpx 22rpx 18rpx;
color: #766252;
font-size: clamp(13px, 21rpx, 16px);
text-align: center;
}
.promotion-strip__error button {
margin: 0;
padding: 8rpx 18rpx;
border: 1rpx solid rgba(159, 23, 15, 0.36);
border-radius: 999rpx;
background: transparent;
color: #9f170f;
font-size: clamp(13px, 21rpx, 16px);
line-height: 1.5;
}
.promotion-strip__error button::after {
border: 0;
}
.promotion-strip__feedback {
display: block;
padding: 14rpx 22rpx 0;
color: #9f170f;
font-size: clamp(13px, 21rpx, 16px);
}
</style>
+84 -17
View File
@@ -1,25 +1,70 @@
<!-- 公共组件根页面底部导航仅维护家谱家族我的三项已确认入口及其透明图标 -->
<template> <template>
<view class="app-tabbar"> <view
<view v-for="item in items" :key="item.key" class="tab-item" @click="switchTab(item)"> class="app-tabbar"
<image class="tab-icon" :src="active === item.key ? item.activeIcon : item.icon" mode="aspectFit" /> :class="{ 'app-tabbar--light': tone === 'light' }"
<text :class="['tab-label', { active: active === item.key }]">{{ item.label }}</text> role="tablist"
</view> aria-label="主要导航"
>
<button
v-for="item in items"
:key="item.key"
class="tab-item"
role="tab"
:aria-selected="active === item.key"
:aria-label="`${item.label}${active === item.key ? '当前页面' : ''}`"
hover-class="tab-item--pressed"
@click="switchRoot(item)"
>
<image
class="tab-icon"
:src="active === item.key ? item.activeIcon : item.icon"
mode="aspectFit"
aria-hidden="true"
/>
<text :class="['tab-label', 'app-single-line', { active: active === item.key }]">{{
item.label
}}</text>
</button>
</view> </view>
</template> </template>
<script setup> <script setup>
const props = defineProps({ active: { type: String, required: true } }) import { goRoot } from "@/utils/navigation/gateway.js";
const props = defineProps({
active: { type: String, required: true },
tone: { type: String, default: "default" },
});
const items = [ const items = [
{ key: 'genealogy', label: '家谱', path: '/pages/genealogy/g01-my-genealogies', icon: '/static/assets/foundation/transparent/tab-genealogy.png', activeIcon: '/static/assets/foundation/transparent/tab-genealogy-active.png' }, {
{ key: 'family', label: '家族', path: '/pages/family/f01-family-feed', icon: '/static/assets/foundation/transparent/tab-family.png', activeIcon: '/static/assets/foundation/transparent/tab-family-active.png' }, key: "genealogy",
{ key: 'profile', label: '我的', path: '/pages/profile/m01-profile-home', icon: '/static/assets/foundation/transparent/tab-profile.png', activeIcon: '/static/assets/foundation/transparent/tab-profile-active.png' } label: "家谱",
] routeKey: "G01",
icon: "/static/assets/foundation/transparent/tab-genealogy.png",
activeIcon:
"/static/assets/foundation/transparent/tab-genealogy-active.png",
},
{
key: "family",
label: "消息",
routeKey: "F01",
icon: "/static/assets/foundation/transparent/tab-family.png",
activeIcon: "/static/assets/foundation/transparent/tab-family-active.png",
},
{
key: "profile",
label: "我的",
routeKey: "M01",
icon: "/static/assets/foundation/transparent/tab-profile.png",
activeIcon: "/static/assets/foundation/transparent/tab-profile-active.png",
},
];
const switchTab = (item) => { const switchRoot = (item) => {
if (item.key !== props.active) uni.reLaunch({ url: item.path }) if (item.key === props.active) return;
} return goRoot(item.routeKey);
};
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">
@@ -30,22 +75,39 @@ const switchTab = (item) => {
left: 0; left: 0;
z-index: 20; z-index: 20;
display: flex; display: flex;
height: 112rpx; min-height: 112rpx;
padding-bottom: env(safe-area-inset-bottom); padding-bottom: env(safe-area-inset-bottom);
box-sizing: content-box; box-sizing: content-box;
border-top: 1rpx solid $gold-soft; border-top: 1rpx solid $gold-soft;
background: $paper-white; background: $paper-white;
} }
.app-tabbar--light {
border-top-color: #e5dac4;
background: #fffdf9;
}
.tab-item { .tab-item {
display: flex; display: flex;
min-width: 0;
min-height: 88rpx; min-height: 88rpx;
flex: 1; flex: 1;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
margin: 0;
padding: 4rpx 0; padding: 4rpx 0;
box-sizing: border-box; box-sizing: border-box;
border: 0;
border-radius: 0;
background: transparent;
line-height: normal;
}
.tab-item::after {
border: 0;
}
.tab-item--pressed {
opacity: 0.72;
} }
.tab-icon { .tab-icon {
@@ -55,11 +117,16 @@ const switchTab = (item) => {
} }
.tab-label { .tab-label {
max-width: 100%;
color: $ink-muted; color: $ink-muted;
font-size: 30rpx; font-size: clamp(17px, 30rpx, 22px);
letter-spacing: 2rpx; letter-spacing: 2rpx;
line-height: 1.2; line-height: 1.2;
text-align: center;
} }
.tab-label.active { color: $brand-red; font-weight: 700; } .tab-label.active {
color: $brand-red;
font-weight: 700;
}
</style> </style>
+47
View File
@@ -0,0 +1,47 @@
<template>
<view
v-show="visible"
class="app-toast"
role="status"
aria-live="polite"
aria-atomic="true"
>
<text class="app-toast__copy">{{ message }}</text>
</view>
</template>
<script setup>
defineProps({
visible: { type: Boolean, default: false },
message: { type: String, default: "" },
});
</script>
<style scoped lang="scss">
@use "../styles/adaptive-frame-profiles.scss" as adaptive;
.app-toast {
position: fixed;
z-index: 100;
top: calc(env(safe-area-inset-top) + 24rpx);
left: 50%;
display: flex;
width: 590rpx;
max-width: calc(100vw - 48rpx);
min-height: 82rpx;
align-items: center;
justify-content: center;
@include adaptive.adaptive-feedback-toast;
transform: translateX(-50%);
pointer-events: none;
}
.app-toast__copy {
z-index: 1;
padding: 14rpx 30rpx;
color: #5c4330;
font-size: clamp(15px, 25rpx, 18px);
font-weight: 500;
line-height: 1.45;
text-align: center;
}
</style>
+72
View File
@@ -0,0 +1,72 @@
<template>
<view class="batch-management">
<view class="batch-management__copy">
<text>{{ active ? `已选择 ${selectedCount}` : `批量管理${resourceName}` }}</text>
<text>{{ active ? "仅可选择有删除权限的内容" : "可一次选择多条内容移入回收站" }}</text>
</view>
<view class="batch-management__actions">
<template v-if="active">
<AppButton compact type="secondary" :disabled="busy" :label="allSelected ? '取消全选' : '全选'" @click="$emit('toggle-all')" />
<AppButton compact :disabled="!selectedCount || busy" :label="busy ? '正在删除' : `删除 ${selectedCount || ''}`.trim()" @click="$emit('delete')" />
<AppButton compact type="secondary" :disabled="busy" label="完成" @click="$emit('finish')" />
</template>
<AppButton v-else compact type="secondary" :label="`管理${resourceName}`" @click="$emit('start')" />
</view>
</view>
</template>
<script setup>
import AppButton from "@/components/AppButton.vue";
defineProps({
active: { type: Boolean, default: false },
selectedCount: { type: Number, default: 0 },
allSelected: { type: Boolean, default: false },
busy: { type: Boolean, default: false },
resourceName: { type: String, required: true },
});
defineEmits(["start", "finish", "toggle-all", "delete"]);
</script>
<style scoped lang="scss">
.batch-management {
display: flex;
align-items: center;
justify-content: space-between;
gap: 18rpx;
margin-bottom: 20rpx;
padding: 20rpx 22rpx;
border: 1rpx solid rgba(159, 35, 35, 0.18);
background: rgba(255, 252, 242, 0.92);
}
.batch-management__copy {
min-width: 0;
flex: 1;
}
.batch-management__copy text {
display: block;
color: $ink-muted;
font-size: clamp(12px, 20rpx, 15px);
line-height: 1.45;
}
.batch-management__copy text:first-child {
color: $ink;
font-size: clamp(14px, 23rpx, 17px);
font-weight: 700;
}
.batch-management__actions {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 10rpx;
}
@media (max-width: 420px) {
.batch-management {
align-items: stretch;
flex-direction: column;
}
.batch-management__actions {
justify-content: flex-start;
}
}
</style>
+42
View File
@@ -0,0 +1,42 @@
<template>
<button
class="batch-selection-mark"
:class="{ 'batch-selection-mark--selected': selected }"
role="checkbox"
:aria-checked="selected"
:aria-label="`${selected ? '取消选择' : '选择'}${label}`"
@click.stop="$emit('toggle')"
>
{{ selected ? "已选择" : "选择" }}
</button>
</template>
<script setup>
defineProps({
selected: { type: Boolean, default: false },
label: { type: String, required: true },
});
defineEmits(["toggle"]);
</script>
<style scoped lang="scss">
.batch-selection-mark {
min-width: 100rpx;
min-height: 64rpx;
margin: 0;
padding: 0 16rpx;
border: 1rpx solid rgba(159, 35, 35, 0.34);
border-radius: 8rpx;
background: rgba(255, 255, 255, 0.78);
color: $brand-red;
font-size: clamp(12px, 20rpx, 15px);
line-height: 64rpx;
}
.batch-selection-mark::after {
border: 0;
}
.batch-selection-mark--selected {
background: $brand-red;
color: #fffaf0;
}
</style>
@@ -0,0 +1,281 @@
<template>
<AppDialog
:visible="visible"
eyebrow="内容密码"
title="找回内容密码"
confirm-text="关闭"
:close-on-mask="!isBusy"
@confirm="requestClose"
@cancel="requestClose"
>
<view class="recovery-dialog">
<AppLoading v-if="capabilityState === 'loading'" text="正在核对找回方式" />
<template v-else-if="capabilityState === 'ready' && capability.enabled">
<text class="recovery-dialog__copy"
>验证码将发送至当前账号绑定手机号 {{ capability.maskedPhone }}验证通过后可设置新的内容密码</text
>
<view class="recovery-dialog__code-row">
<input
v-model.trim="smsCode"
type="number"
maxlength="4"
placeholder="4位短信验证码"
/>
<AppButton
compact
type="secondary"
:disabled="isBusy || cooldownSeconds > 0"
:label="codeButtonLabel"
@click="sendRecoveryCode"
/>
</view>
<input
v-model="newPassword"
password
maxlength="128"
placeholder="新的内容密码(8至128位)"
/>
<input
v-model="confirmedPassword"
password
maxlength="128"
placeholder="再次输入新的内容密码"
/>
<AppButton
block
:disabled="isBusy"
:label="resetting ? '正在重置' : '验证并重置密码'"
@click="resetContentPassword"
/>
</template>
<view v-else class="recovery-dialog__state">
<text>{{ capabilityError || capability.disabledReason || "当前内容暂不支持密码找回。" }}</text>
<AppButton
v-if="capabilityState === 'error'"
compact
type="secondary"
label="重试"
@click="loadCapability"
/>
</view>
<text v-if="formError" class="recovery-dialog__error" role="alert">{{ formError }}</text>
<text v-if="resultNotice" class="recovery-dialog__notice" role="status">{{ resultNotice }}</text>
<text class="recovery-dialog__security"
>服务端必须校验当前账号资源查看权限和短信票据并对发送与重置操作限流审计</text
>
</view>
</AppDialog>
</template>
<script setup>
import { computed, onUnmounted, reactive, ref, watch } from "vue";
import AppButton from "@/components/AppButton.vue";
import AppDialog from "@/components/AppDialog.vue";
import AppLoading from "@/components/AppLoading.vue";
import { contentPasswordRecoveryApi } from "@/services/api/content-password-recovery-service.js";
import {
createRequestController,
isRequestCancelled,
} from "@/services/api/request-controller.js";
import { getRequestErrorMessage } from "@/services/api/request-error-message.js";
import { createNonIdempotentWriteGuard } from "@/utils/non-idempotent-write-guard.js";
const props = defineProps({
visible: Boolean,
genealogyId: { type: String, required: true },
resourceType: { type: String, required: true },
resourceId: { type: String, required: true },
});
const emit = defineEmits(["close", "complete", "busy-change"]);
const capabilityState = ref("idle");
const capability = reactive({
enabled: false,
maskedPhone: "",
disabledReason: "",
});
const capabilityError = ref("");
const formError = ref("");
const resultNotice = ref("");
const smsCode = ref("");
const newPassword = ref("");
const confirmedPassword = ref("");
const cooldownSeconds = ref(0);
const sendingCode = ref(false);
const resetting = ref(false);
const capabilityController = createRequestController();
const codeController = createRequestController();
const resetController = createRequestController();
const resetGuard = createNonIdempotentWriteGuard();
let cooldownTimer = null;
let componentActive = true;
const isBusy = computed(() => sendingCode.value || resetting.value);
const codeButtonLabel = computed(() =>
sendingCode.value
? "正在发送"
: cooldownSeconds.value > 0
? `${cooldownSeconds.value}s 后重发`
: "发送验证码",
);
watch(isBusy, (busy) => emit("busy-change", busy), { immediate: true });
const stopCooldown = () => {
if (cooldownTimer) clearInterval(cooldownTimer);
cooldownTimer = null;
};
const startCooldown = (seconds) => {
stopCooldown();
cooldownSeconds.value = seconds;
if (seconds <= 0) return;
cooldownTimer = setInterval(() => {
cooldownSeconds.value = Math.max(0, cooldownSeconds.value - 1);
if (cooldownSeconds.value === 0) stopCooldown();
}, 1000);
};
const resetForm = () => {
smsCode.value = "";
newPassword.value = "";
confirmedPassword.value = "";
formError.value = "";
resultNotice.value = "";
};
const loadCapability = async () => {
capabilityController.abort();
capabilityState.value = "loading";
capabilityError.value = "";
try {
const result = await contentPasswordRecoveryApi.getCapability(
props.genealogyId,
props.resourceType,
props.resourceId,
{ requestController: capabilityController },
);
if (!componentActive || !props.visible) return;
Object.assign(capability, result);
startCooldown(result.cooldownSeconds);
capabilityState.value = "ready";
} catch (error) {
if (!componentActive || !props.visible || isRequestCancelled(error)) return;
capabilityError.value = getRequestErrorMessage(error, "找回方式暂时无法读取,请稍后重试。");
capabilityState.value = "error";
}
};
const sendRecoveryCode = async () => {
if (!capability.enabled || sendingCode.value || resetting.value || cooldownSeconds.value > 0) return;
sendingCode.value = true;
formError.value = "";
try {
const delivery = await contentPasswordRecoveryApi.sendCode(
props.genealogyId,
props.resourceType,
props.resourceId,
{ requestController: codeController },
);
if (!componentActive || !props.visible) return;
startCooldown(delivery.cooldownSeconds);
resultNotice.value = `验证码已发送至 ${capability.maskedPhone}`;
} catch (error) {
if (componentActive && props.visible && !isRequestCancelled(error)) {
formError.value = getRequestErrorMessage(error, "验证码发送失败,请稍后重试。");
}
} finally {
if (componentActive) sendingCode.value = false;
}
};
const resetContentPassword = async () => {
if (!capability.enabled || isBusy.value) return;
if (!/^\d{4}$/.test(smsCode.value)) {
formError.value = "请输入4位短信验证码。";
return;
}
if (newPassword.value.length < 8 || newPassword.value.length > 128) {
formError.value = "请输入8至128位新的内容密码。";
return;
}
if (newPassword.value !== confirmedPassword.value) {
formError.value = "两次输入的新密码不一致。";
return;
}
const payload = { smsCode: smsCode.value, newPassword: newPassword.value };
const resetAttempt = resetGuard.begin(payload);
if (resetAttempt === null) {
formError.value = "上次重置结果待确认,请先关闭窗口并尝试使用新密码解锁。";
return;
}
resetting.value = true;
formError.value = "";
try {
await contentPasswordRecoveryApi.resetPassword(
props.genealogyId,
props.resourceType,
props.resourceId,
payload,
{ requestController: resetController },
);
if (!componentActive || !props.visible) return;
emit("complete", payload.newPassword);
emit("close");
resetForm();
} catch (error) {
const isOutcomeUnknown = resetGuard.recordFailure(resetAttempt, error);
if (!componentActive || !props.visible) return;
if (isOutcomeUnknown) {
emit("complete", payload.newPassword);
formError.value = "重置结果待确认,请关闭窗口后尝试使用新密码解锁,不要重复提交。";
return;
}
if (isRequestCancelled(error)) return;
formError.value = getRequestErrorMessage(error, "内容密码重置失败,请检查验证码后重试。");
} finally {
if (componentActive) resetting.value = false;
}
};
const requestClose = () => {
if (!isBusy.value) emit("close");
};
watch(
() => props.visible,
(visible) => {
if (!visible) {
capabilityController.abort();
codeController.abort();
resetController.abort();
stopCooldown();
resetForm();
capabilityState.value = "idle";
return;
}
resetForm();
void loadCapability();
},
);
onUnmounted(() => {
componentActive = false;
stopCooldown();
capabilityController.abort();
codeController.abort();
resetController.abort();
});
</script>
<style scoped lang="scss">
.recovery-dialog { width: 100%; margin-top: 16rpx; text-align: left; }
.recovery-dialog__copy,
.recovery-dialog__security,
.recovery-dialog__state text,
.recovery-dialog__error,
.recovery-dialog__notice { display: block; color: $ink-muted; font-size: clamp(13px, 22rpx, 16px); line-height: 1.55; }
.recovery-dialog input { width: 100%; min-height: var(--app-touch-min); margin-top: 16rpx; padding: 0 18rpx; box-sizing: border-box; border: 1rpx solid rgba(142, 95, 41, .3); border-radius: 8rpx; background: rgba(255, 255, 255, .68); }
.recovery-dialog__code-row { display: flex; align-items: center; margin-top: 16rpx; gap: 12rpx; }
.recovery-dialog__code-row input { min-width: 0; flex: 1; margin-top: 0; }
.recovery-dialog__code-row .app-button { width: auto; flex: 0 0 auto; }
.recovery-dialog > .app-button { margin-top: 20rpx; }
.recovery-dialog__state .app-button { width: auto; margin-top: 16rpx; }
.recovery-dialog__error { margin-top: 14rpx; color: $brand-red; }
.recovery-dialog__notice { margin-top: 14rpx; color: #426b58; }
.recovery-dialog__security { margin-top: 18rpx; padding-top: 14rpx; border-top: 1rpx solid rgba(142, 95, 41, .18); font-size: clamp(12px, 19rpx, 14px); }
</style>
-266
View File
@@ -1,266 +0,0 @@
<!-- 公共组件G01 已创建已加入家谱的题签列表项保持透明题签框叠在纸纹底图上 -->
<template>
<view
class="genealogy-card"
:class="{ 'genealogy-card--current': selected }"
@click="$emit('select', genealogy)"
>
<image
class="row-frame"
src="/static/assets/modules/genealogy/transparent/list-slip-frame.png"
mode="scaleToFill"
/>
<view class="surname-seal">
<image
class="surname-seal-frame"
src="/static/assets/modules/genealogy/transparent/row-seal-frame.png"
mode="scaleToFill"
/>
<view class="surname-seal-copy">
<text class="seal-title">家谱</text>
</view>
</view>
<view class="card-main">
<view class="card-title-row">
<text class="card-name">{{ genealogy.name }}</text>
<view class="card-side">
<text class="card-role">{{ role }}</text>
<image
class="card-chevron"
src="/static/assets/foundation/transparent/chevron-right.png"
mode="aspectFit"
/>
</view>
</view>
<view class="card-detail-row">
<view class="card-metas">
<view class="card-meta-item">
<image
class="card-meta-icon"
src="/static/assets/foundation/transparent/meta-location.png"
mode="aspectFit"
/>
<text class="card-meta">{{ genealogy.location }}</text>
</view>
<view class="card-meta-item">
<image
class="card-meta-icon"
src="/static/assets/foundation/transparent/meta-member.png"
mode="aspectFit"
/>
<text class="card-meta">{{ genealogy.memberCount }} 位成员</text>
</view>
</view>
<view class="card-updated">
<text>更新于 {{ genealogy.updatedAt }}</text>
</view>
</view>
</view>
</view>
</template>
<script setup>
defineProps({
genealogy: { type: Object, required: true },
role: { type: String, required: true },
selected: { type: Boolean, default: false },
});
defineEmits(["select"]);
</script>
<style scoped lang="scss">
.genealogy-card {
position: relative;
display: flex;
min-height: 156rpx;
align-items: center;
padding: 18rpx 24rpx;
box-sizing: border-box;
background: transparent;
}
.genealogy-card--current {
background: transparent;
}
.row-frame {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
z-index: 0;
width: 100%;
height: 100%;
pointer-events: none;
}
.surname-seal {
position: relative;
z-index: 1;
display: flex;
width: 72rpx;
height: 112rpx;
flex: 0 0 auto;
align-items: center;
justify-content: center;
margin-right: 22rpx;
color: #fff5df;
}
.surname-seal-frame {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
z-index: 0;
width: 100%;
height: 100%;
}
.surname-seal-copy {
position: relative;
z-index: 1;
display: flex;
height: 82rpx;
align-items: center;
justify-content: center;
}
.seal-title {
color: #fff5df;
font-family: "STKaiti", "KaiTi", serif;
font-size: 30rpx;
font-weight: 700;
letter-spacing: 2rpx;
line-height: 1;
writing-mode: vertical-rl;
}
.genealogy-card:not(.genealogy-card--current) .surname-seal-frame {
opacity: 0.32;
}
.genealogy-card:not(.genealogy-card--current) .seal-title {
color: $ink-muted;
}
.card-main {
position: relative;
z-index: 1;
display: flex;
min-width: 0;
flex: 1;
flex-direction: column;
}
.card-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.card-meta {
white-space: nowrap;
}
.card-title-row {
display: flex;
min-width: 0;
align-items: center;
justify-content: space-between;
}
.card-name {
color: $ink;
font-family: "STKaiti", "KaiTi", serif;
font-size: 37rpx;
font-weight: 700;
}
.card-detail-row {
display: flex;
min-width: 0;
align-items: center;
justify-content: space-between;
margin-top: 12rpx;
}
.card-metas {
display: flex;
min-width: 0;
align-items: center;
}
.card-meta-item {
display: flex;
min-width: 0;
align-items: center;
margin-right: 14rpx;
}
.card-meta-icon {
width: 28rpx;
height: 28rpx;
margin-right: 6rpx;
}
.card-meta {
color: $ink-muted;
font-size: 26rpx;
}
.card-updated {
display: flex;
flex: 0 0 auto;
align-items: center;
margin-left: 10rpx;
color: $ink-muted;
font-size: 22rpx;
white-space: nowrap;
}
.card-side {
position: relative;
z-index: 1;
display: flex;
min-width: 0;
flex: 0 0 auto;
flex-direction: row;
align-items: center;
justify-content: flex-end;
margin-left: 20rpx;
}
.card-role {
color: $ink-muted;
font-size: 24rpx;
white-space: nowrap;
}
.card-chevron {
width: 34rpx;
height: 34rpx;
margin-left: 14rpx;
}
@media screen and (max-width: 340px) {
.genealogy-card {
min-height: 148rpx;
padding-right: 18rpx;
padding-left: 18rpx;
}
.surname-seal {
width: 66rpx;
height: 100rpx;
margin-right: 16rpx;
}
.card-name {
font-size: 32rpx;
}
.card-meta {
font-size: 22rpx;
}
.card-updated {
display: none;
}
.card-side {
margin-left: 12rpx;
}
.card-chevron {
width: 28rpx;
height: 28rpx;
margin-left: 8rpx;
}
}
</style>
+66
View File
@@ -0,0 +1,66 @@
<template>
<view
class="module-page-background"
:class="`module-page-background--${module}`"
aria-hidden="true"
>
<image
class="module-page-background__image"
:src="source"
mode="aspectFill"
/>
</view>
</template>
<script setup>
import { computed } from "vue";
const props = defineProps({
module: { type: String, required: true },
});
const sources = {
tree: "/static/assets/modules/tree/opaque/tree-page-background-long.png",
family:
"/static/assets/modules/family/opaque/family-page-background-long.png",
records:
"/static/assets/modules/records/opaque/records-page-background-long.png",
notification:
"/static/assets/modules/notification/opaque/notification-page-background-long.png",
profile:
"/static/assets/modules/profile/opaque/profile-page-background-long.png",
};
const source = computed(() => sources[props.module] || sources.profile);
</script>
<style scoped lang="scss">
.module-page-background {
position: fixed;
z-index: 0;
inset: 0;
overflow: hidden;
background: #f5eee2;
pointer-events: none;
}
.module-page-background__image {
position: absolute;
right: 0;
bottom: 0;
left: 0;
width: 100%;
height: 100%;
opacity: 0.36;
}
.module-page-background--family .module-page-background__image {
opacity: 0.32;
}
.module-page-background--records .module-page-background__image {
opacity: 0.3;
}
.module-page-background--notification .module-page-background__image {
opacity: 0.28;
}
.module-page-background--profile .module-page-background__image {
opacity: 0.3;
}
</style>
+262 -51
View File
@@ -1,55 +1,150 @@
<!-- 公共组件根页朱砂页头与普通返回页头根页背景图层由统一基础资产提供 -->
<template> <template>
<view class="page-header-slot" :class="{ 'page-header-slot--root': root }">
<view class="page-header" :class="{ 'page-header--root': root }"> <view class="page-header" :class="{ 'page-header--root': root }">
<!-- 根页头部使用不透明朱砂底图与透明祠堂线稿两层完整资产 --> <!-- 根页头部使用不透明朱砂底图与透明祠堂线稿两层完整资产 -->
<image v-if="root" class="header-texture" src="/static/assets/foundation/opaque/root-header-cinnabar.jpg" mode="scaleToFill" /> <image
<image v-if="root" class="header-hall" src="/static/assets/foundation/transparent/root-header-hall.png" mode="aspectFit" /> v-if="root"
class="header-texture"
src="/static/assets/foundation/opaque/root-header-cinnabar.jpg"
mode="scaleToFill"
aria-hidden="true"
/>
<image
v-if="root"
class="header-hall"
src="/static/assets/foundation/transparent/root-header-hall.png"
mode="aspectFit"
aria-hidden="true"
/>
<view class="header-side header-side--left"> <view class="header-side header-side--left">
<view v-if="root" class="header-icon-button" @click="$emit('brand')"> <view
<image class="header-logo" src="/static/assets/foundation/transparent/brand-seal.png" mode="aspectFit" /> v-if="root"
class="header-brand"
aria-hidden="true"
>
<image
class="header-logo"
src="/static/assets/foundation/transparent/brand-seal.png"
mode="aspectFit"
aria-hidden="true"
/>
</view> </view>
<view v-else class="header-back" @click="goBack">{{ backLabel }}</view> <button
v-else
class="header-back"
aria-label="返回上一页"
hover-class="header-back--pressed"
@click="handleBack"
>
<image
class="header-back__icon"
src="/static/assets/foundation/transparent/chevron-right.png"
mode="aspectFit"
aria-hidden="true"
/>
</button>
</view> </view>
<text class="header-title">{{ title }}</text> <view
class="header-project-lockup"
:class="{ 'header-project-lockup--root': root }"
>
<image
v-if="!root"
class="header-project-lockup__logo"
src="/static/assets/foundation/transparent/brand-seal.png"
mode="aspectFit"
aria-hidden="true"
/>
<text class="header-project-name">代代相传</text>
</view>
<text class="header-title app-single-line">{{ title }}</text>
<view class="header-side header-side--right"> <view class="header-side header-side--right">
<view v-if="root" class="header-icon-button header-notice" @click="$emit('notice')"> <button
<image class="header-notice-icon" src="/static/assets/foundation/transparent/notice.png" mode="aspectFit" /> v-if="root && notice"
class="header-icon-button header-notice"
:aria-label="unreadCount > 0 ? `打开消息中心,${unreadCount} 条未读` : '打开消息中心'"
@click="$emit('notice')"
>
<image
class="header-notice-icon"
src="/static/assets/foundation/transparent/notice.png"
mode="aspectFit"
aria-hidden="true"
/>
<view v-if="unreadCount > 0" class="notice-dot"></view> <view v-if="unreadCount > 0" class="notice-dot"></view>
</button>
<button
v-else-if="root && action"
class="header-action header-action--root app-single-line"
@click="$emit('action')"
>{{ action }}</button>
<button v-else-if="action" class="header-action app-single-line" @click="$emit('action')">{{
action
}}</button>
<view
v-else
class="header-action-placeholder"
aria-hidden="true"
></view>
</view> </view>
<view v-else class="header-action" @click="$emit('action')">{{ action }}</view>
</view> </view>
</view> </view>
</template> </template>
<script setup> <script setup>
import { goBack } from "@/utils/navigation/gateway.js";
const props = defineProps({ const props = defineProps({
title: { type: String, required: true }, title: { type: String, required: true },
action: { type: String, default: '' }, action: { type: String, default: "" },
backLabel: { type: String, default: '返回' },
root: { type: Boolean, default: false }, root: { type: Boolean, default: false },
unreadCount: { type: Number, default: 0 } unreadCount: { type: Number, default: 0 },
}) notice: { type: Boolean, default: false },
customBack: { type: Boolean, default: false },
});
const goBack = () => uni.navigateBack() const emit = defineEmits(["notice", "action", "back"]);
const handleBack = () => {
if (props.customBack) {
emit("back");
return;
}
return goBack();
};
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">
.page-header-slot {
height: calc(104rpx + var(--status-bar-height, 0px));
flex: none;
}
.page-header-slot--root {
height: calc(88rpx + var(--status-bar-height, 0px));
}
.page-header { .page-header {
position: fixed;
top: 0;
right: 0;
left: 0;
z-index: 30;
display: flex; display: flex;
align-items: center; align-items: center;
height: 104rpx; height: calc(104rpx + var(--status-bar-height, 0px));
padding: 0 24rpx; padding: 0 24rpx;
padding-top: var(--status-bar-height, 0px);
box-sizing: border-box; box-sizing: border-box;
background: $brand-red; background: $brand-red;
color: #fff9ed; color: #fff9ed;
transform: translateZ(0);
} }
.page-header--root { .page-header--root {
position: relative; height: calc(88rpx + var(--status-bar-height, 0px));
z-index: 3;
height: calc(124rpx + var(--status-bar-height, 0px));
padding-top: var(--status-bar-height, 0px); padding-top: var(--status-bar-height, 0px);
background-color: #b52e22; background-color: #b52e22;
overflow: hidden; overflow: hidden;
@@ -72,63 +167,178 @@ const goBack = () => uni.navigateBack()
bottom: -2rpx; bottom: -2rpx;
left: -48rpx; left: -48rpx;
z-index: 1; z-index: 1;
width: 476rpx; width: 408rpx;
height: 166rpx; height: 126rpx;
opacity: .29; opacity: 0.29;
pointer-events: none; pointer-events: none;
} }
.page-header--root .header-side, .page-header--root .header-side,
.page-header--root .header-title { position: relative; z-index: 2; } .page-header--root .header-title {
z-index: 2;
}
.header-side { .header-side {
display: flex; display: flex;
flex: 0 0 120rpx;
width: 120rpx; width: 120rpx;
min-height: 88rpx; min-height: 88rpx;
align-items: center; align-items: center;
} }
.header-side--left { justify-content: flex-start; } .header-side--left {
.header-side--right { justify-content: flex-end; } justify-content: flex-start;
}
.header-icon-button { .header-side--right {
position: relative; justify-content: flex-end;
display: flex; }
width: 88rpx; .page-header:not(.page-header--root) .header-side--right {
height: 88rpx; flex-basis: 152rpx;
align-items: center; width: 152rpx;
justify-content: center;
} }
.header-logo { width: 66rpx; height: 66rpx; } .header-icon-button {
.header-notice-icon { width: 48rpx; height: 48rpx; } display: grid;
width: 88rpx;
height: 88rpx;
place-items: center;
margin: 0;
padding: 0;
border: 0;
background: transparent;
line-height: normal;
}
.header-brand {
display: grid;
width: 88rpx;
min-height: 88rpx;
place-items: center;
margin: 0;
padding: 0;
border: 0;
background: transparent;
line-height: normal;
}
.header-icon-button::after,
.header-back::after,
.header-action::after {
border: 0;
}
.header-logo {
width: 66rpx;
height: 66rpx;
}
.header-notice-icon {
width: 48rpx;
height: 48rpx;
}
.header-logo,
.header-notice-icon,
.notice-dot {
grid-area: 1 / 1;
}
.notice-dot { .notice-dot {
position: absolute; align-self: start;
top: 15rpx; justify-self: end;
right: 13rpx;
width: 14rpx; width: 14rpx;
height: 14rpx; height: 14rpx;
margin-top: 15rpx;
margin-right: 13rpx;
border: 2rpx solid $brand-red; border: 2rpx solid $brand-red;
border-radius: 50%; border-radius: 50%;
background: #fff9ed; background: #fff9ed;
} }
.header-back,
.header-action { .header-action {
display: flex;
width: 100%; width: 100%;
font-size: 27rpx; min-height: 88rpx;
align-items: center;
justify-content: flex-end;
margin: 0;
padding: 0;
border: 0;
background: transparent;
color: #ffe3a7;
font-size: clamp(16px, 27rpx, 20px);
line-height: normal;
text-align: right;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.header-action-placeholder {
width: 100%;
min-height: 88rpx;
}
.header-action--root {
color: #ffe3a7;
font-weight: 700;
} }
.header-back { text-align: left; } .header-back {
.header-action { color: #ffe3a7; text-align: right; } display: flex;
width: 88rpx;
height: 88rpx;
align-items: center;
justify-content: flex-start;
margin: 0;
padding: 0;
border: 0;
background: transparent;
line-height: normal;
}
.header-back__icon {
width: 42rpx;
height: 42rpx;
filter: brightness(0) invert(1);
transform: scaleX(-1);
}
.header-back--pressed {
opacity: 0.62;
}
.header-project-lockup {
position: absolute;
top: calc(var(--status-bar-height, 0px) + 52rpx);
left: 112rpx;
z-index: 3;
display: flex;
align-items: center;
gap: 7rpx;
transform: translateY(-50%);
pointer-events: none;
}
.header-project-lockup--root {
top: calc(var(--status-bar-height, 0px) + 44rpx);
left: 102rpx;
}
.header-project-lockup__logo {
width: 34rpx;
height: 34rpx;
flex: 0 0 auto;
}
.header-project-name {
color: #ffe3a7;
font-family: "STKaiti", "KaiTi", serif;
font-size: clamp(11px, 19rpx, 14px);
font-weight: 700;
letter-spacing: 3rpx;
line-height: 1;
white-space: nowrap;
}
.header-title { .header-title {
min-width: 0;
flex: 1; flex: 1;
overflow: hidden; overflow: hidden;
color: #fff9ed; color: #fff9ed;
font-family: serif; font-family: serif;
font-size: 35rpx; font-size: clamp(19px, 35rpx, 24px);
font-weight: 700; font-weight: 700;
letter-spacing: 4rpx; letter-spacing: 4rpx;
text-align: center; text-align: center;
@@ -138,17 +348,18 @@ const goBack = () => uni.navigateBack()
.page-header--root .header-title { .page-header--root .header-title {
color: #ffe3a7; color: #ffe3a7;
font-family: 'STKaiti', 'KaiTi', serif; font-family: "STKaiti", "KaiTi", serif;
font-size: 48rpx; font-size: clamp(21px, 40rpx, 25px);
letter-spacing: 5rpx; letter-spacing: 4rpx;
transform: translateY(2rpx);
} }
.page-header--root .header-logo { .page-header--root .header-logo {
width: 80rpx; width: 64rpx;
height: 80rpx; height: 64rpx;
transform: translate(-6rpx, -6rpx); transform: translate(-2rpx, -2rpx);
} }
.page-header--root .header-notice { transform: translateY(4rpx); } .page-header--root .header-notice {
transform: none;
}
</style> </style>
+45
View File
@@ -0,0 +1,45 @@
<template>
<view class="referral-qr-code" aria-label="推荐注册链接二维码">
<image v-if="imageSource" :src="imageSource" mode="aspectFit" />
</view>
</template>
<script setup>
import { computed } from "vue";
import createQrCode from "qrcode-generator";
const props = defineProps({
value: {
type: String,
required: true,
},
});
const imageSource = computed(() => {
const value = props.value.trim();
if (!value) return "";
const qrCode = createQrCode(0, "M");
qrCode.addData(value, "Byte");
qrCode.make();
return qrCode.createDataURL(8, 16);
});
</script>
<style scoped lang="scss">
.referral-qr-code {
display: flex;
width: 280rpx;
height: 280rpx;
align-items: center;
justify-content: center;
padding: 14rpx;
border: 1rpx solid rgba(128, 89, 49, 0.24);
border-radius: 8rpx;
background: #fff;
}
.referral-qr-code image {
width: 100%;
height: 100%;
}
</style>
+94
View File
@@ -0,0 +1,94 @@
<template>
<view
class="login-advertisement"
:class="{ 'login-advertisement--linked': advertisement.targetUrl }"
:role="advertisement.targetUrl ? 'button' : 'img'"
:aria-label="advertisement.targetUrl
? `${advertisement.title}查看详情`
: advertisement.title"
@click="openAdvertisement"
>
<image :src="advertisement.image" mode="aspectFill" />
</view>
</template>
<script setup>
import { onBeforeUnmount, onMounted, ref } from "vue";
import {
createRequestController,
isRequestCancelled,
} from "@/services/api/request-controller.js";
import { siteContentApi } from "@/services/api/site-content-service.js";
import { openSiteContentTarget } from "@/utils/navigation/gateway.js";
const fallbackAdvertisement = Object.freeze({
title: "寻根问祖",
image: "/static/assets/modules/genealogy/opaque/home-ad-heritage-hall.png",
targetUrl: "",
});
const advertisement = ref(fallbackAdvertisement);
const promotionRequestController = createRequestController();
let isMounted = true;
const loadAdvertisement = async () => {
try {
const promotions = await siteContentApi.getPromotions({
placement: "home_banner",
requestController: promotionRequestController,
});
if (!isMounted) return;
const promotion = promotions.find((entry) => entry.coverFile?.accessUrl);
if (!promotion) return;
advertisement.value = {
title: promotion.title,
image: promotion.coverFile.accessUrl,
targetUrl: promotion.targetUrl,
};
} catch (error) {
if (!isMounted || isRequestCancelled(error)) return;
advertisement.value = fallbackAdvertisement;
}
};
const openAdvertisement = async () => {
if (!advertisement.value.targetUrl) return;
try {
await openSiteContentTarget(advertisement.value.targetUrl, () => {
uni.showToast({ title: "广告内容暂时打不开", icon: "none" });
});
} catch {
uni.showToast({ title: "广告内容暂时打不开", icon: "none" });
}
};
onMounted(loadAdvertisement);
onBeforeUnmount(() => {
isMounted = false;
promotionRequestController.abort();
});
</script>
<style scoped lang="scss">
.login-advertisement {
width: 100%;
height: 128rpx;
flex: 0 0 auto;
overflow: hidden;
box-sizing: border-box;
margin-top: 22rpx;
border: 1rpx solid rgba(142, 96, 44, 0.38);
border-radius: 12rpx;
background: #f1ece2;
}
.login-advertisement--linked:active {
opacity: 0.78;
}
.login-advertisement image {
display: block;
width: 100%;
height: 100%;
}
</style>
+98
View File
@@ -0,0 +1,98 @@
<template>
<view class="auth-shell">
<view class="auth-shell__header">
<image
class="auth-shell__header-image"
src="/static/assets/modules/auth/opaque/auth-header.png"
mode="widthFix"
/>
<view class="auth-shell__brand-lockup">
<image
class="auth-shell__seal"
src="/static/assets/foundation/transparent/brand-seal.png"
mode="aspectFit"
/>
<text class="auth-shell__project-name">代代相传</text>
</view>
</view>
<view class="auth-shell__paper"><slot /></view>
<slot name="overlay" />
</view>
</template>
<style scoped lang="scss">
.auth-shell {
display: flex;
flex-direction: column;
box-sizing: border-box;
width: 100%;
min-height: var(--app-viewport-height);
overflow-x: hidden;
background: #940400;
color: #493323;
}
.auth-shell__header {
position: relative;
flex: 0 0 auto;
box-sizing: border-box;
width: 100%;
height: calc(var(--app-safe-top) + min(36.5vw, 175px));
overflow: hidden;
padding-top: var(--app-safe-top);
background: #940400;
}
.auth-shell__header-image {
display: block;
width: 100%;
height: auto;
pointer-events: none;
}
.auth-shell__brand-lockup {
position: absolute;
top: calc(var(--app-safe-top) + clamp(46px, 11.5vw, 58px));
left: 50%;
display: flex;
align-items: center;
gap: 12rpx;
transform: translateX(-50%);
white-space: nowrap;
}
.auth-shell__seal {
position: static;
width: clamp(56px, 17vw, 78px);
height: clamp(67px, 20.4vw, 94px);
pointer-events: none;
}
.auth-shell__paper {
display: flex;
flex: 1;
box-sizing: border-box;
width: 100%;
padding-bottom: var(--app-safe-bottom);
background-color: #f7f0e5;
background-image:
url("/static/assets/modules/auth/opaque/sign-in-backdrop.png"),
url("/static/assets/foundation/opaque/auth-page-paper.jpg");
background-position: center calc(-48.18vw), center top;
background-size: 100% auto, 100% auto;
background-repeat: no-repeat, repeat-y;
}
.auth-shell__project-name {
color: #f8dfa6;
font-family: "STKaiti", "KaiTi", serif;
font-size: clamp(14px, 24rpx, 17px);
font-weight: 700;
letter-spacing: 4rpx;
line-height: 1;
white-space: nowrap;
}
</style>
+355
View File
@@ -0,0 +1,355 @@
<template>
<view
v-show="visible"
class="tac-layer"
:aria-hidden="visible ? 'false' : 'true'"
@click.stop
>
<view
id="jiapu-tac-host"
:prop="renderContext"
:change:prop="tacRenderer.onContextChange"
/>
</view>
</template>
<script>
export default {
name: "TacVerification",
props: {
visible: {
type: Boolean,
default: false,
},
context: {
type: Object,
default: null,
},
},
emits: ["success", "failure", "error", "cancel"],
computed: {
renderContext() {
return {
...(this.context || {}),
visible: this.visible,
};
},
},
methods: {
handleTacSuccess(payload) {
this.$emit("success", payload);
},
handleTacFailure(payload) {
this.$emit("failure", payload);
},
handleTacError(payload) {
this.$emit("error", payload);
},
handleTacCancel(payload) {
this.$emit("cancel", payload);
},
},
};
</script>
<script module="tacRenderer" lang="renderjs">
const TAC_STYLE_URL = "./static/tac/css/tac.css";
const TAC_SCRIPT_URL = "./static/tac/js/tac.min.js";
const TAC_ADAPTER_URL = "./static/tac/js/jiapu-tac-adapter.js";
const loadStyle = (href) => {
const existing = document.querySelector(`link[data-jiapu-tac="${href}"]`);
if (existing && (existing.dataset.jiapuState === "loaded" || existing.sheet)) {
return Promise.resolve();
}
if (existing && existing.dataset.jiapuState === "loading") {
return new Promise((resolve, reject) => {
existing.addEventListener("load", resolve, { once: true });
existing.addEventListener("error", () => reject(new Error(`安全验证样式加载失败:${href}`)), { once: true });
});
}
if (existing) existing.remove();
return new Promise((resolve, reject) => {
const link = document.createElement("link");
link.rel = "stylesheet";
link.href = href;
link.setAttribute("data-jiapu-tac", href);
link.dataset.jiapuState = "loading";
link.onload = () => {
link.dataset.jiapuState = "loaded";
resolve();
};
link.onerror = () => {
link.remove();
reject(new Error(`安全验证样式加载失败:${href}`));
};
document.head.appendChild(link);
});
};
const loadScript = (src, ready) => {
if (ready()) return Promise.resolve();
const existing = document.querySelector(`script[data-jiapu-tac="${src}"]`);
if (existing && existing.dataset.jiapuState === "loading") {
return new Promise((resolve, reject) => {
existing.addEventListener("load", () => {
if (ready()) {
existing.dataset.jiapuState = "loaded";
resolve();
} else {
existing.remove();
reject(new Error(`安全验证脚本未提供预期能力:${src}`));
}
}, { once: true });
existing.addEventListener("error", () => {
existing.remove();
reject(new Error(`安全验证脚本加载失败:${src}`));
}, { once: true });
});
}
if (existing) existing.remove();
return new Promise((resolve, reject) => {
const script = document.createElement("script");
script.src = src;
script.setAttribute("data-jiapu-tac", src);
script.dataset.jiapuState = "loading";
script.onload = () => {
if (!ready()) {
script.remove();
reject(new Error(`安全验证脚本未提供预期能力:${src}`));
return;
}
script.dataset.jiapuState = "loaded";
resolve();
};
script.onerror = () => {
script.remove();
reject(new Error(`安全验证脚本加载失败:${src}`));
};
document.head.appendChild(script);
});
};
export default {
data() {
return {
generation: 0,
tac: null,
requestContext: null,
challenge: null,
activeXhr: null,
fatalSent: false,
completionSent: false,
};
},
methods: {
async onContextChange(nextContext) {
const nextVisible = Boolean(nextContext && nextContext.visible === true);
this.generation += 1;
const generation = this.generation;
this.destroyTac();
this.requestContext = nextContext || null;
this.challenge = null;
this.fatalSent = false;
this.completionSent = false;
if (!nextContext || nextContext.visible !== true) return;
try {
await Promise.all([
loadStyle(TAC_STYLE_URL),
loadScript(TAC_SCRIPT_URL, () => Boolean(window.TAC && window.CaptchaConfig)),
]);
await loadScript(TAC_ADAPTER_URL, () => Boolean(window.JiapuTacAdapter));
if (generation !== this.generation || !this.requestContext || this.requestContext.visible !== true) return;
this.createTac();
} catch (error) {
if (generation !== this.generation || !this.requestContext || this.requestContext.visible !== true) return;
this.notifyFatal(error);
}
},
createTac() {
if (!window.TAC || !window.CaptchaConfig || !window.JiapuTacAdapter) {
throw new Error("安全验证组件未完整加载");
}
this.completionSent = false;
const generation = this.generation;
const requestContext = this.requestContext;
const isCurrent = () =>
generation === this.generation &&
this.requestContext === requestContext &&
requestContext.visible === true;
const config = new window.CaptchaConfig({
bindEl: "#jiapu-tac-host",
requestCaptchaDataUrl: this.requestContext.challengeUrl,
validCaptchaUrl: this.requestContext.verifyUrl,
requestHeaders: {
clientid: this.requestContext.clientId,
},
validSuccess: (response, captcha, tac) => {
if (!isCurrent() || this.completionSent) return;
// 供应商脚本在动画结束、窗口销毁等边界下可能重复触发回调。
// 先封闭本轮并使所有旧闭包失效,再通知逻辑层,避免重复发短信或重复提交认证。
this.completionSent = true;
this.generation += 1;
this.abortActiveRequest();
const verificationData = response && response.data;
tac.destroyWindow();
this.tac = null;
this.$ownerInstance.callMethod("handleTacSuccess", {
requestId: requestContext.requestId,
validToken: verificationData && verificationData.validToken,
expireSeconds: verificationData && verificationData.expireSeconds,
});
},
validFail: (response, captcha, tac) => {
if (!isCurrent()) return;
this.$ownerInstance.callMethod("handleTacFailure", {
requestId: requestContext.requestId,
message: (response && response.msg) || "行为验证未通过,请重试",
});
tac.reloadCaptcha();
},
btnCloseFun: (event, tac) => {
if (!isCurrent() || this.completionSent) return;
this.completionSent = true;
this.generation += 1;
this.abortActiveRequest();
tac.destroyWindow();
this.tac = null;
this.$ownerInstance.callMethod("handleTacCancel", {
requestId: requestContext.requestId,
});
},
btnRefreshFun: (event, tac) => {
if (isCurrent()) tac.reloadCaptcha();
},
});
// SDK 原生传输会把空体 HTTP 500 当成功,并且 verify 只看外层 code。
// 此处统一替换为严格 2xx JSON 传输,再由唯一适配器验证 passed 与 validToken。
config.doSendRequest = (options) => this.sendStrictRequest(options);
config.addRequestChain({
preRequest: (type, request) => this.beforeRequest(type, request),
postRequest: (type, request, response) => this.afterRequest(type, response),
});
this.tac = new window.TAC(config);
this.tac.init();
},
beforeRequest(type, request) {
if (type === "requestCaptchaData") {
request.data = {
tenantId: this.requestContext.tenantId,
subject: this.requestContext.subject,
};
return true;
}
if (type === "validCaptcha") {
request.data = window.JiapuTacAdapter.buildVerifyBody(
request.data,
this.requestContext,
this.challenge,
);
}
return true;
},
afterRequest(type, response) {
try {
if (type === "requestCaptchaData") {
const mapped = window.JiapuTacAdapter.normalizeChallengeResponse(response, this.requestContext);
this.challenge = mapped.challenge;
this.replaceResponse(response, mapped.sdkResponse);
} else if (type === "validCaptcha") {
const mapped = window.JiapuTacAdapter.normalizeVerifyResponse(response);
this.replaceResponse(response, mapped);
}
} catch (error) {
const failure = window.JiapuTacAdapter.toSdkFailure(error);
this.replaceResponse(response, failure);
if (type === "requestCaptchaData") this.notifyFatal(error);
}
return true;
},
replaceResponse(target, source) {
Object.keys(target).forEach((key) => delete target[key]);
Object.assign(target, source);
},
sendStrictRequest(options) {
return new Promise((resolve) => {
this.abortActiveRequest();
const generation = this.generation;
const xhr = new XMLHttpRequest();
this.activeXhr = xhr;
let settled = false;
const settle = (response) => {
if (settled || generation !== this.generation || this.activeXhr !== xhr) return;
settled = true;
this.activeXhr = null;
resolve(response);
};
xhr.open(options.method || "POST", options.url);
xhr.timeout = 15000;
Object.keys(options.headers || {}).forEach((name) => {
xhr.setRequestHeader(name, String(options.headers[name]));
});
xhr.onreadystatechange = () => {
if (xhr.readyState !== XMLHttpRequest.DONE) return;
if (!(xhr.status >= 200 && xhr.status < 300)) {
settle({ code: xhr.status || 503, msg: `安全验证服务请求失败(${xhr.status || "网络异常"}` });
return;
}
try {
const parsedResponse = JSON.parse(xhr.responseText);
settle(
parsedResponse && typeof parsedResponse === "object"
? parsedResponse
: { code: 502, msg: "安全验证服务返回无效数据" },
);
} catch (error) {
settle({ code: 502, msg: "安全验证服务返回了非 JSON 数据" });
}
};
xhr.onerror = () => settle({ code: 503, msg: "安全验证服务网络连接失败" });
xhr.ontimeout = () => settle({ code: 504, msg: "安全验证服务请求超时" });
const payload = typeof options.data === "string" ? options.data : JSON.stringify(options.data || {});
xhr.send(payload);
});
},
abortActiveRequest() {
const xhr = this.activeXhr;
this.activeXhr = null;
if (!xhr || xhr.readyState === XMLHttpRequest.DONE) return;
xhr.onreadystatechange = null;
xhr.onerror = null;
xhr.ontimeout = null;
xhr.onabort = null;
xhr.abort();
},
notifyFatal(error) {
if (this.fatalSent) return;
this.fatalSent = true;
this.$ownerInstance.callMethod("handleTacError", {
requestId: this.requestContext && this.requestContext.requestId,
message: (error && error.message) || "安全验证暂不可用",
});
},
destroyTac() {
this.abortActiveRequest();
if (this.tac) this.tac.destroyWindow();
this.tac = null;
const host = document.querySelector("#jiapu-tac-host");
if (host) host.innerHTML = "";
},
},
};
</script>
<style scoped>
.tac-layer {
position: fixed;
inset: 0;
z-index: 1200;
display: flex;
align-items: center;
justify-content: center;
}
</style>
+718
View File
@@ -0,0 +1,718 @@
<template>
<view class="comment-section">
<view class="comment-section__heading">
<text class="section-title">评论</text>
<button class="comment-entry" @click="focusCommentEditor">写评论</button>
</view>
<AppLoading v-if="commentState === 'loading'" text="正在读取评论" />
<view v-else-if="commentState === 'list'" class="comment-list">
<view v-for="comment in comments" :key="comment.id" class="comment-card">
<view class="comment-card__heading">
<text>{{ comment.author }}</text>
<text>{{ formatMinuteTimestamp(comment.time) || "刚刚" }}</text>
</view>
<text class="comment-card__content">{{ comment.content }}</text>
<view class="comment-card__actions">
<AppButton
compact
type="secondary"
label="回复"
@click="startReply(comment)"
/>
<AppButton
v-if="comment.canDelete && !comment.userDeleted"
compact
type="secondary"
label="删除"
@click="requestDeleteComment(comment)"
/>
<AppButton
v-if="comment.replyCount"
compact
type="secondary"
:label="replyState(comment.id) === 'ready' ? '收起回复' : `查看 ${comment.replyCount} 条回复`"
@click="toggleReplies(comment)"
/>
</view>
<view
v-if="replyState(comment.id) !== 'closed'"
class="comment-replies"
>
<text v-if="replyState(comment.id) === 'loading'" class="reply-state">
正在读取回复
</text>
<view v-else-if="replyState(comment.id) === 'ready'">
<text v-if="!replyRows(comment.id).length" class="reply-state">
暂未读取到回复
</text>
<view
v-for="reply in replyRows(comment.id)"
:key="reply.id"
class="reply-card"
>
<view class="reply-card__heading">
<text>{{ reply.author }}</text>
<text>{{ formatMinuteTimestamp(reply.time) || "刚刚" }}</text>
</view>
<text v-if="reply.parentAuthor" class="reply-card__target">
回复 {{ reply.parentAuthor }}
</text>
<text class="reply-card__content">{{ reply.content }}</text>
<view class="reply-card__actions">
<AppButton
compact
type="secondary"
label="回复"
@click="startReply(reply, comment)"
/>
<AppButton
v-if="reply.canDelete && !reply.userDeleted"
compact
type="secondary"
label="删除回复"
@click="requestDeleteComment(reply, comment)"
/>
</view>
</view>
<AppButton
v-if="shouldShowReplyMore(comment.id)"
compact
type="secondary"
:disabled="replyMoreState(comment.id) === 'loading'"
:label="replyMoreLabel(comment.id)"
@click="loadMoreReplies(comment)"
/>
</view>
<text v-else class="reply-state reply-state--error">
回复暂时无法读取
<text role="button" @click="loadReplies(comment)">重新读取</text>
</text>
</view>
</view>
<AppButton
v-if="shouldShowCommentMore"
compact
type="secondary"
:disabled="commentMoreState === 'loading'"
:label="commentMoreLabel"
@click="loadMoreComments"
/>
</view>
<view v-else class="comment-state-copy">
<text>{{ commentStateCopy }}</text>
<AppButton
v-if="commentState === 'error'"
compact
type="secondary"
label="重新加载评论"
@click="reload"
/>
</view>
<view class="comment-editor">
<view v-if="replyTarget" class="reply-target">
<text>正在回复 {{ replyTarget.author }}</text>
<text role="button" @click="clearReplyTarget">取消回复</text>
</view>
<textarea
v-model="commentDraft"
auto-height
maxlength="1000"
:placeholder="replyTarget ? `回复 ${replyTarget.author}` : '写下你的评论'"
placeholder-class="comment-editor__placeholder"
:focus="commentFocused"
@input="commentError = ''"
@blur="commentFocused = false"
/>
<text v-if="commentError" class="comment-error">{{ commentError }}</text>
<AppButton
block
:disabled="isSubmittingComment"
:label="isSubmittingComment ? '正在提交' : replyTarget ? '发表回复' : '发表评论'"
@click="submitComment"
/>
</view>
</view>
<AppDialog
:visible="commentDeleteVisible"
:close-on-mask="false"
eyebrow="删除评论"
title="确认删除这条评论?"
message="删除后评论内容将不再显示。"
:confirm-text="deletingComment ? '正在删除' : '确认删除'"
cancel-text="取消"
show-cancel
@confirm="confirmDeleteComment"
@cancel="closeDeleteConfirmation"
/>
</template>
<script setup>
import { computed, nextTick, onMounted, onUnmounted, ref } from "vue";
import AppButton from "@/components/AppButton.vue";
import AppDialog from "@/components/AppDialog.vue";
import AppLoading from "@/components/AppLoading.vue";
import {
createRequestController,
isRequestCancelled
} from "@/services/api/request-controller.js";
import { familyFeedApi } from "@/services/api/family-feed-service.js";
import { getRequestErrorMessage } from "@/services/api/request-error-message.js";
import { formatMinuteTimestamp } from "@/utils/display-time.js";
import { createNonIdempotentWriteGuard } from "@/utils/non-idempotent-write-guard.js";
const props = defineProps({
genealogyId: {
type: String,
required: true,
},
feedId: {
type: String,
required: true,
},
refreshFeedSummary: {
type: Function,
required: true,
},
});
const COMMENT_PAGE_SIZE = 20;
const commentState = ref("loading");
const comments = ref([]);
const currentCommentPage = ref(1);
const totalCommentCount = ref(0);
const commentMoreState = ref("idle");
const replyThreadsByCommentId = ref({});
const replyTarget = ref(null);
const commentDraft = ref("");
const commentFocused = ref(false);
const commentError = ref("");
const isSubmittingComment = ref(false);
const commentDeleteVisible = ref(false);
const deletingComment = ref(false);
const commentDeleteTarget = ref(null);
const commentReadRequestController = createRequestController();
const commentSubmissionRequestController = createRequestController();
const commentDeletionRequestController = createRequestController();
const commentCreateGuard = createNonIdempotentWriteGuard();
const replyRequestControllers = new Map();
let componentActive = true;
const hasValidContext = computed(() =>
/^[1-9]\d*$/.test(props.genealogyId) && /^[1-9]\d*$/.test(props.feedId),
);
const hasMoreComments = computed(() =>
comments.value.length < totalCommentCount.value,
);
const shouldShowCommentMore = computed(() =>
hasMoreComments.value || ["loading", "error"].includes(commentMoreState.value),
);
const commentMoreLabel = computed(() => {
if (commentMoreState.value === "loading") return "正在加载评论";
if (commentMoreState.value === "error") return "加载失败,重新加载";
return "继续加载评论";
});
const commentStateCopy = computed(() =>
commentState.value === "empty"
? "还没有评论,欢迎留下第一句话。"
: "评论暂时无法读取,稍后可重新进入本页查看。",
);
const replyRequestController = (commentId) => {
const controllerKey = String(commentId);
if (!replyRequestControllers.has(controllerKey)) {
replyRequestControllers.set(controllerKey, createRequestController());
}
return replyRequestControllers.get(controllerKey);
};
const emptyReplyThread = () => ({
state: "closed",
rows: [],
page: 1,
total: 0,
moreState: "idle",
});
const replyThread = (commentId) =>
replyThreadsByCommentId.value[String(commentId)] || emptyReplyThread();
const updateReplyThread = (commentId, changes) => {
const threadId = String(commentId);
replyThreadsByCommentId.value = {
...replyThreadsByCommentId.value,
[threadId]: { ...replyThread(threadId), ...changes },
};
};
const replyState = (commentId) => replyThread(commentId).state;
const replyRows = (commentId) => replyThread(commentId).rows;
const replyHasMore = (commentId) => {
const thread = replyThread(commentId);
return thread.rows.length < thread.total;
};
const replyMoreState = (commentId) => replyThread(commentId).moreState;
const shouldShowReplyMore = (commentId) =>
replyHasMore(commentId) || ["loading", "error"].includes(replyMoreState(commentId));
const replyMoreLabel = (commentId) => {
const state = replyMoreState(commentId);
if (state === "loading") return "正在加载回复";
if (state === "error") return "加载失败,重新加载";
return "继续加载回复";
};
const reload = async () => {
if (!hasValidContext.value) return;
commentReadRequestController.abort();
commentState.value = "loading";
currentCommentPage.value = 1;
commentMoreState.value = "idle";
try {
const commentPage = await familyFeedApi.getFeedCommentPage(
props.genealogyId,
props.feedId,
{ pageNum: 1, pageSize: COMMENT_PAGE_SIZE },
{ requestController: commentReadRequestController },
);
if (!componentActive) return;
comments.value = commentPage.rows;
totalCommentCount.value = commentPage.total;
commentMoreState.value =
comments.value.length < totalCommentCount.value ? "idle" : "done";
commentState.value = commentPage.rows.length ? "list" : "empty";
} catch (error) {
if (!componentActive || isRequestCancelled(error)) return;
commentState.value = "error";
}
};
const loadMoreComments = async () => {
if (!hasMoreComments.value || commentMoreState.value === "loading") return;
commentMoreState.value = "loading";
try {
const nextPage = currentCommentPage.value + 1;
const commentPage = await familyFeedApi.getFeedCommentPage(
props.genealogyId,
props.feedId,
{ pageNum: nextPage, pageSize: COMMENT_PAGE_SIZE },
{ requestController: commentReadRequestController },
);
if (!componentActive) return;
const knownCommentIds = new Set(
comments.value.map((comment) => String(comment.id)),
);
comments.value = comments.value.concat(
commentPage.rows.filter(
(comment) => !knownCommentIds.has(String(comment.id)),
),
);
currentCommentPage.value = nextPage;
totalCommentCount.value = commentPage.total;
commentMoreState.value =
comments.value.length < totalCommentCount.value ? "idle" : "done";
} catch (error) {
if (!componentActive || isRequestCancelled(error)) return;
commentMoreState.value = "error";
}
};
const loadReplies = async (comment) => {
const commentId = String(comment?.id || "");
if (!hasValidContext.value || !/^[1-9]\d*$/.test(commentId)) return;
updateReplyThread(commentId, { state: "loading" });
try {
const replyPage = await familyFeedApi.getCommentReplyPage(
props.genealogyId,
props.feedId,
commentId,
{ pageNum: 1, pageSize: COMMENT_PAGE_SIZE },
{ requestController: replyRequestController(commentId) },
);
if (!componentActive) return;
updateReplyThread(commentId, {
state: "ready",
rows: replyPage.rows,
page: 1,
total: replyPage.total,
moreState: replyPage.rows.length < replyPage.total ? "idle" : "done",
});
} catch (error) {
if (!componentActive || isRequestCancelled(error)) return;
updateReplyThread(commentId, { state: "error" });
}
};
const loadMoreReplies = async (comment) => {
const commentId = String(comment?.id || "");
const thread = replyThread(commentId);
if (!replyHasMore(commentId) || thread.moreState === "loading") return;
updateReplyThread(commentId, { moreState: "loading" });
try {
const nextPage = thread.page + 1;
const replyPage = await familyFeedApi.getCommentReplyPage(
props.genealogyId,
props.feedId,
commentId,
{ pageNum: nextPage, pageSize: COMMENT_PAGE_SIZE },
{ requestController: replyRequestController(commentId) },
);
if (!componentActive) return;
const currentReplies = replyRows(commentId);
const knownReplyIds = new Set(
currentReplies.map((reply) => String(reply.id)),
);
const replies = currentReplies.concat(
replyPage.rows.filter((reply) => !knownReplyIds.has(String(reply.id))),
);
updateReplyThread(commentId, {
rows: replies,
page: nextPage,
total: replyPage.total,
moreState: replies.length < replyPage.total ? "idle" : "done",
});
} catch (error) {
if (!componentActive || isRequestCancelled(error)) return;
updateReplyThread(commentId, { moreState: "error" });
}
};
const toggleReplies = (comment) => {
const commentId = String(comment?.id || "");
if (replyState(commentId) === "ready") {
updateReplyThread(commentId, { state: "closed" });
return;
}
void loadReplies(comment);
};
const startReply = (comment, rootComment = comment) => {
// 写入使用实际父评论,刷新则使用所属一级评论;两者不能合并成同一个 ID。
replyTarget.value = {
id: comment.id,
author: comment.author,
rootComment,
};
commentError.value = "";
};
const clearReplyTarget = () => {
replyTarget.value = null;
commentError.value = "";
};
const focusCommentEditor = () => {
if (typeof uni?.pageScrollTo !== "function") return;
uni.pageScrollTo({
selector: ".comment-editor",
duration: 240,
complete: () => {
commentFocused.value = false;
nextTick(() => {
if (componentActive) commentFocused.value = true;
});
},
});
};
const requestDeleteComment = (comment, parent = null) => {
if (!comment?.canDelete || comment.userDeleted || deletingComment.value) return;
commentDeleteTarget.value = { comment, parent };
commentDeleteVisible.value = true;
commentError.value = "";
};
const closeDeleteConfirmation = () => {
if (deletingComment.value) return;
commentDeleteVisible.value = false;
commentDeleteTarget.value = null;
};
const confirmDeleteComment = async () => {
const deletion = commentDeleteTarget.value;
if (!deletion?.comment?.canDelete || deletingComment.value) return;
deletingComment.value = true;
let deletionCommitted = false;
try {
await familyFeedApi.deleteFeedComment(
props.genealogyId,
props.feedId,
deletion.comment.id,
{ requestController: commentDeletionRequestController },
);
deletionCommitted = true;
if (!componentActive) return;
commentDeleteVisible.value = false;
commentDeleteTarget.value = null;
await reload();
if (deletion.parent) await loadReplies(deletion.parent);
const feedRefreshed = await props.refreshFeedSummary();
if (componentActive && feedRefreshed === false) {
commentError.value = "评论已删除,动态统计暂时未更新。";
}
} catch (error) {
if (!componentActive) return;
if (deletionCommitted) {
commentDeleteVisible.value = false;
commentDeleteTarget.value = null;
commentError.value = "评论已删除,动态统计暂时未更新。";
return;
}
if (isRequestCancelled(error)) return;
commentError.value = getRequestErrorMessage(
error,
"这条评论删除失败,请稍后重试。",
);
commentDeleteVisible.value = false;
} finally {
if (componentActive) deletingComment.value = false;
}
};
const submitComment = async () => {
if (isSubmittingComment.value || !hasValidContext.value) return;
const commentContent = commentDraft.value.trim();
if (!commentContent) {
commentError.value = "请填写评论内容";
return;
}
const target = replyTarget.value;
const payload = {
commentContent,
...(target ? { parentCommentId: target.id } : {}),
};
const createAttempt = commentCreateGuard.begin(payload);
if (createAttempt === null) {
commentError.value =
"上次评论结果暂时无法确认,请先刷新评论列表,避免重复发表。";
return;
}
isSubmittingComment.value = true;
commentError.value = "";
let commentCommitted = false;
try {
await familyFeedApi.createFeedComment(
props.genealogyId,
props.feedId,
payload,
{ requestController: commentSubmissionRequestController },
);
commentCommitted = true;
if (!componentActive) return;
commentDraft.value = "";
replyTarget.value = null;
await reload();
if (target) {
await loadReplies(target.rootComment);
}
const feedRefreshed = await props.refreshFeedSummary();
if (componentActive && feedRefreshed === false) {
commentError.value = "评论已发布,动态统计暂时未更新。";
}
} catch (error) {
if (!componentActive) return;
if (commentCommitted) {
commentError.value = "评论已发布,但动态统计暂时未更新。";
return;
}
if (commentCreateGuard.recordFailure(createAttempt, error)) {
commentError.value =
"评论结果暂时无法确认,请先刷新评论列表,避免重复发表。";
return;
}
if (isRequestCancelled(error)) return;
commentError.value = getRequestErrorMessage(
error,
"评论提交失败,请稍后重试",
);
} finally {
if (componentActive) isSubmittingComment.value = false;
}
};
defineExpose({ reload });
onMounted(() => {
void reload();
});
onUnmounted(() => {
componentActive = false;
commentReadRequestController.abort();
commentSubmissionRequestController.abort();
commentDeletionRequestController.abort();
replyRequestControllers.forEach((requestController) => requestController.abort());
replyRequestControllers.clear();
});
</script>
<style scoped lang="scss">
@import "../../styles/adaptive-frame-profiles.scss";
.comment-section {
@include adaptive-family-content;
box-sizing: border-box;
margin-top: 18rpx;
padding: 28rpx;
}
.comment-section__heading,
.comment-card__heading,
.reply-card__heading,
.reply-target {
display: flex;
justify-content: space-between;
gap: 18rpx;
}
.comment-section__heading {
align-items: center;
}
.section-title {
display: block;
color: $ink;
font-family: STKaiti, KaiTi, serif;
font-size: clamp(17px, 32rpx, 22px);
font-weight: 700;
}
.comment-entry {
min-width: 144rpx;
min-height: 72rpx;
margin: 0;
padding: 0 18rpx;
border: 0;
background: transparent;
color: $brand-red;
font-size: clamp(14px, 22rpx, 17px);
line-height: 72rpx;
}
.comment-entry::after {
border: 0;
}
.comment-list,
.comment-state-copy {
margin-top: 18rpx;
}
.comment-card {
padding: 18rpx 0;
border-bottom: 1rpx solid rgba(128, 89, 49, 0.16);
}
.comment-card__heading text:first-child {
color: $brand-red;
font-size: clamp(14px, 23rpx, 17px);
font-weight: 700;
}
.reply-card__heading text:first-child,
.reply-target text:first-child {
color: $brand-red;
font-size: clamp(14px, 22rpx, 17px);
font-weight: 700;
}
.comment-card__heading text:last-child,
.reply-card__heading text:last-child,
.reply-card__target,
.reply-state {
color: $ink-muted;
font-size: clamp(13px, 21rpx, 16px);
}
.comment-card__content,
.reply-card__content {
display: block;
color: $ink;
line-height: 1.55;
white-space: pre-wrap;
}
.comment-card__content {
margin-top: 10rpx;
font-size: clamp(15px, 25rpx, 18px);
}
.comment-card__actions,
.reply-card__actions {
display: flex;
flex-wrap: wrap;
gap: 10rpx;
}
.comment-card__actions {
margin-top: 14rpx;
}
.reply-card__actions {
margin-top: 10rpx;
}
.comment-card__actions .app-button,
.reply-card__actions .app-button {
width: auto;
min-width: 132rpx;
}
.comment-card__actions .app-button {
min-height: 58rpx;
padding: 0 16rpx;
}
.comment-replies {
margin-top: 14rpx;
padding: 14rpx 18rpx;
border-left: 4rpx solid rgba(159, 23, 15, 0.3);
background: rgba(135, 94, 52, 0.045);
}
.reply-card + .reply-card {
margin-top: 14rpx;
padding-top: 14rpx;
border-top: 1rpx solid rgba(128, 89, 49, 0.13);
}
.reply-card__target,
.reply-card__content,
.reply-state {
display: block;
margin-top: 6rpx;
}
.reply-card__content {
font-size: clamp(14px, 23rpx, 17px);
}
.reply-state--error {
color: $brand-red;
}
.reply-state--error text {
text-decoration: underline;
}
.comment-state-copy {
display: block;
color: $ink-muted;
font-size: clamp(15px, 24rpx, 18px);
line-height: 1.5;
}
.comment-state-copy .app-button {
width: 260rpx;
max-width: 100%;
margin-top: 16rpx;
}
.comment-editor {
margin-top: 24rpx;
padding-top: 22rpx;
border-top: 1rpx solid rgba(128, 89, 49, 0.18);
}
.reply-target {
margin-bottom: 12rpx;
padding: 12rpx 16rpx;
border: 1rpx solid rgba(159, 23, 15, 0.22);
border-radius: 10rpx;
background: rgba(159, 23, 15, 0.05);
}
.reply-target text:last-child {
color: $brand-red;
font-size: clamp(13px, 21rpx, 16px);
}
.comment-editor textarea {
display: block;
box-sizing: border-box;
width: 100%;
min-height: 130rpx;
padding: 18rpx;
border: 1rpx solid rgba(128, 89, 49, 0.3);
border-radius: 12rpx;
color: $ink;
font-size: clamp(15px, 25rpx, 18px);
line-height: 1.55;
}
.comment-editor__placeholder {
color: #ab9a86;
}
.comment-editor .app-button {
margin-top: 18rpx;
}
.comment-error {
display: block;
margin-top: 10rpx;
color: $brand-red;
font-size: clamp(14px, 22rpx, 17px);
}
</style>
+69
View File
@@ -0,0 +1,69 @@
<template>
<view
v-if="visibleFiles.length"
class="feed-media"
:class="`feed-media--${layout}`"
>
<image
v-for="file in visibleFiles"
:key="file.fileId || file.ossId || file.accessUrl"
class="feed-media__image"
:src="file.accessUrl"
mode="aspectFill"
role="button"
:aria-label="imageLabel"
@click.stop="preview(file)"
/>
</view>
</template>
<script setup>
import { computed } from "vue";
const props = defineProps({
files: { type: Array, default: () => [] },
imageLabel: { type: String, default: "查看动态图片" },
});
const visibleFiles = computed(() =>
props.files.filter((file) => typeof file?.accessUrl === "string" && file.accessUrl),
);
const layout = computed(() => {
if (visibleFiles.value.length === 1) return "single";
if (visibleFiles.value.length === 2) return "double";
return "grid";
});
const preview = (file) => {
const urls = visibleFiles.value.map((item) => item.accessUrl);
if (!file?.accessUrl || !urls.length || typeof uni?.previewImage !== "function") return;
uni.previewImage({ current: file.accessUrl, urls });
};
</script>
<style scoped lang="scss">
.feed-media {
display: grid;
gap: 8rpx;
margin-top: 16rpx;
overflow: hidden;
border-radius: 10rpx;
}
.feed-media--single {
grid-template-columns: minmax(0, 1fr);
}
.feed-media--double {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.feed-media--grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.feed-media__image {
display: block;
width: 100%;
height: 190rpx;
background: rgba(120, 84, 48, 0.08);
}
.feed-media--single .feed-media__image {
height: 360rpx;
}
</style>
+208
View File
@@ -0,0 +1,208 @@
<template>
<view v-if="visible" class="vertical-video-viewer">
<view class="vertical-video-viewer__header">
<button aria-label="关闭上下滑动播放" @click="close">返回</button>
<text>{{ title }}</text>
<text>{{ activeIndex + 1 }}/{{ videos.length }}</text>
</view>
<swiper
class="vertical-video-viewer__swiper"
vertical
:current="activeIndex"
:duration="260"
@change="changeVideo"
>
<swiper-item v-for="(video, index) in videos" :key="video.id">
<view class="vertical-video-viewer__slide">
<video
:id="videoElementId(video)"
class="vertical-video-viewer__player"
:src="video.videoFile.accessUrl"
:poster="video.coverFile?.accessUrl || ''"
:autoplay="visible && index === activeIndex"
loop
controls
object-fit="contain"
/>
<view class="vertical-video-viewer__summary">
<text class="vertical-video-viewer__title">{{ video.title }}</text>
<text v-if="video.description" class="vertical-video-viewer__description">{{ video.description }}</text>
<view class="vertical-video-viewer__actions">
<button :disabled="actionBusy" @click="requestLike(video)">
{{ video.likedByCurrentUser ? "已赞" : "点赞" }} {{ video.likeCount || 0 }}
</button>
<button :disabled="actionBusy" @click="requestComments(video)">
评论 {{ video.commentCount || 0 }}
</button>
</view>
</view>
</view>
</swiper-item>
</swiper>
</view>
</template>
<script setup>
import { nextTick, ref, watch } from "vue";
const props = defineProps({
visible: { type: Boolean, default: false },
videos: { type: Array, default: () => [] },
initialVideoId: { type: [String, Number], default: "" },
title: { type: String, default: "视频播放" },
actionBusy: { type: Boolean, default: false },
});
const emit = defineEmits(["close", "like", "comments"]);
const activeIndex = ref(0);
const videoElementId = (video) => `vertical-video-${String(video.id)}`;
const pauseVideo = (index) => {
const video = props.videos[index];
if (!video) return;
uni.createVideoContext(videoElementId(video))?.pause();
};
const playVideo = (index) => {
const video = props.videos[index];
if (!video) return;
uni.createVideoContext(videoElementId(video))?.play();
};
const resolveInitialIndex = () => {
const requestedId = String(props.initialVideoId || "");
const requestedIndex = props.videos.findIndex(
(video) => String(video.id) === requestedId,
);
return requestedIndex >= 0 ? requestedIndex : 0;
};
watch(
() => props.visible,
async (visible) => {
if (!visible) {
pauseVideo(activeIndex.value);
return;
}
activeIndex.value = resolveInitialIndex();
await nextTick();
playVideo(activeIndex.value);
},
);
const changeVideo = async (event) => {
const nextIndex = Number(event?.detail?.current ?? 0);
if (!Number.isInteger(nextIndex) || nextIndex < 0 || nextIndex >= props.videos.length) return;
pauseVideo(activeIndex.value);
activeIndex.value = nextIndex;
await nextTick();
playVideo(activeIndex.value);
};
const close = () => {
pauseVideo(activeIndex.value);
emit("close");
};
const requestLike = (video) => emit("like", video);
const requestComments = (video) => {
pauseVideo(activeIndex.value);
emit("comments", video);
};
</script>
<style scoped lang="scss">
.vertical-video-viewer {
position: fixed;
z-index: 40;
inset: 0;
display: flex;
flex-direction: column;
color: #fff;
background: #080808;
}
.vertical-video-viewer__header {
display: grid;
min-height: 88rpx;
padding: calc(12rpx + env(safe-area-inset-top)) 24rpx 12rpx;
grid-template-columns: 120rpx 1fr 120rpx;
align-items: center;
background: rgba(0, 0, 0, 0.82);
text-align: center;
}
.vertical-video-viewer__header button,
.vertical-video-viewer__actions button {
min-height: 64rpx;
border: 1rpx solid rgba(255, 255, 255, 0.35);
border-radius: 32rpx;
color: #fff;
background: rgba(0, 0, 0, 0.42);
font-size: 26rpx;
line-height: 1;
}
.vertical-video-viewer__header button {
padding: 0 20rpx;
justify-self: start;
}
.vertical-video-viewer__header text:nth-child(2) {
overflow: hidden;
font-weight: 700;
text-overflow: ellipsis;
white-space: nowrap;
}
.vertical-video-viewer__header text:last-child {
justify-self: end;
color: rgba(255, 255, 255, 0.72);
}
.vertical-video-viewer__swiper,
.vertical-video-viewer__slide {
min-height: 0;
flex: 1;
}
.vertical-video-viewer__slide {
position: relative;
display: flex;
height: 100%;
align-items: center;
background: #080808;
}
.vertical-video-viewer__player {
width: 100%;
height: 100%;
}
.vertical-video-viewer__summary {
position: absolute;
right: 0;
bottom: 0;
left: 0;
padding: 80rpx 30rpx calc(28rpx + env(safe-area-inset-bottom));
background: linear-gradient(transparent, rgba(0, 0, 0, 0.86));
pointer-events: none;
}
.vertical-video-viewer__summary text {
display: block;
}
.vertical-video-viewer__title {
font-size: 34rpx;
font-weight: 700;
}
.vertical-video-viewer__description {
display: -webkit-box !important;
overflow: hidden;
margin-top: 12rpx;
color: rgba(255, 255, 255, 0.82);
font-size: 26rpx;
line-height: 1.55;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
.vertical-video-viewer__actions {
display: flex;
margin-top: 22rpx;
gap: 16rpx;
pointer-events: auto;
}
.vertical-video-viewer__actions button {
min-width: 150rpx;
padding: 0 24rpx;
}
@media (prefers-reduced-motion: reduce) {
.vertical-video-viewer__swiper { scroll-behavior: auto; }
}
</style>
+156
View File
@@ -0,0 +1,156 @@
<template>
<view v-if="visible" class="add-dialog-layer" @click="emit('close')">
<view class="add-dialog" @click.stop>
<view class="add-dialog__content">
<view class="add-dialog__body">
<view class="add-dialog__heading">
<text class="dialog-title">添加家谱</text>
<text class="dialog-copy">建议先搜索已有家谱避免重复创建</text>
<text v-if="creationQuota" class="dialog-copy">
{{ creationQuota.createRemaining === -1
? "当前可继续创建家谱"
: `还可创建 ${creationQuota.createRemaining} 部家谱` }}
</text>
<view
class="add-dialog__close"
role="button"
aria-label="关闭"
hover-class="action-hover"
@click="emit('close')"
>
<image
class="add-dialog__close-icon"
src="/static/assets/modules/genealogy/transparent/dialog-close.png"
mode="aspectFit"
/>
</view>
</view>
<view class="add-dialog__actions">
<AppButton block label="搜索家谱" @click="emit('search')" />
<AppButton
block
type="secondary"
label="继续创建家谱"
:disabled="creationQuota?.canCreate === false"
@click="emit('create')"
/>
</view>
</view>
</view>
</view>
</view>
</template>
<script setup>
import AppButton from "@/components/AppButton.vue";
defineProps({
visible: { type: Boolean, required: true },
creationQuota: { type: Object, default: null },
});
const emit = defineEmits(["close", "search", "create"]);
</script>
<style scoped lang="scss">
@use "../../styles/adaptive-frame-profiles.scss" as adaptive;
.add-dialog-layer {
position: fixed;
z-index: 40;
inset: 0;
display: flex;
align-items: flex-end;
justify-content: center;
box-sizing: border-box;
background: rgba(34, 20, 12, 0.68);
}
.add-dialog {
@include adaptive.adaptive-genealogy-add-sheet;
width: 100%;
min-height: 780rpx;
max-height: calc(100vh - 80rpx);
}
.add-dialog__content {
display: flex;
min-height: 780rpx;
max-height: calc(100vh - 80rpx);
flex-direction: column;
align-items: stretch;
box-sizing: border-box;
padding: 96rpx 52rpx calc(96rpx + env(safe-area-inset-bottom));
overflow-y: auto;
}
.add-dialog__body {
margin: auto 0;
}
.add-dialog__heading {
display: grid;
grid-template-columns: minmax(0, 1fr) 96rpx;
}
.add-dialog .dialog-title,
.add-dialog .dialog-copy {
display: block;
grid-column: 1;
text-align: left;
}
.dialog-title {
color: $brand-red;
font-family: "STKaiti", "KaiTi", serif;
font-size: clamp(22px, 42rpx, 28px);
font-weight: 700;
letter-spacing: 4rpx;
}
.dialog-copy {
margin-top: 12rpx;
color: $ink-muted;
font-size: clamp(15px, 25rpx, 18px);
line-height: 1.5;
}
.add-dialog__close {
display: flex;
grid-column: 2;
grid-row: 1 / span 2;
align-self: start;
justify-self: end;
width: 80rpx;
height: 80rpx;
align-items: center;
justify-content: center;
margin-right: -22rpx;
}
.add-dialog__close-icon {
width: 80rpx;
height: 80rpx;
}
.add-dialog__actions {
display: flex;
flex-direction: column;
align-items: center;
margin: 62rpx -32rpx 0;
}
.add-dialog__actions > .app-button {
width: 595rpx;
max-width: 100%;
min-height: 96rpx;
}
.add-dialog__actions > .app-button + .app-button {
margin-top: 24rpx;
}
.action-hover {
opacity: 0.82;
}
</style>
+296
View File
@@ -0,0 +1,296 @@
<template>
<view
class="genealogy-card"
:class="{ 'genealogy-card--current': selected }"
role="button"
:aria-label="accessibleLabel"
hover-class="genealogy-card--pressed"
@click="$emit('select', genealogy)"
>
<image
class="row-frame"
src="/static/assets/modules/genealogy/transparent/list-slip-frame.png"
mode="scaleToFill"
/>
<view class="genealogy-book" aria-hidden="true">
<image
class="genealogy-book__cover"
src="/static/assets/foundation/opaque/book-blank.png"
mode="aspectFit"
/>
<view
v-if="genealogy.surname"
class="genealogy-book__spine-title"
>
<text
v-for="(character, index) in getBookSpineTitle(genealogy.surname)"
:key="`${index}-${character}`"
class="genealogy-book__spine-character"
>{{ character }}</text>
</view>
</view>
<view class="card-main">
<view class="card-title-row">
<text class="card-name">{{ genealogy.name }}</text>
<image
class="card-chevron"
src="/static/assets/foundation/transparent/chevron-right.png"
mode="aspectFit"
/>
</view>
<view class="card-detail-row">
<view class="card-meta-item card-meta-item--location">
<image
class="card-meta-icon"
src="/static/assets/foundation/transparent/meta-location.png"
mode="aspectFit"
/>
<text class="card-meta">{{ genealogy.location }}</text>
</view>
<view class="card-meta-item card-meta-item--members">
<image
class="card-meta-icon"
src="/static/assets/foundation/transparent/meta-member.png"
mode="aspectFit"
/>
<text class="card-meta">{{ genealogy.memberCount }} 位成员</text>
</view>
<view class="card-trailing">
<text class="card-role app-single-line">{{ role }}</text>
<text v-if="genealogy.createTime" class="card-updated app-single-line"
>创建于 {{ formatGenealogyDate(genealogy.createTime) }}</text
>
</view>
</view>
</view>
</view>
</template>
<script setup>
import { computed } from "vue";
const props = defineProps({
genealogy: { type: Object, required: true },
role: { type: String, required: true },
selected: { type: Boolean, default: false },
});
defineEmits(["select"]);
const formatGenealogyDate = (value) => {
const text = String(value || "").trim();
return text.length >= 10 ? text.slice(0, 10) : text;
};
const getBookSpineTitle = (value) =>
Array.from(`${String(value || "").trim()}氏家谱`);
const accessibleLabel = computed(
() =>
`${props.genealogy.name}${props.role}${props.selected ? ',当前家谱' : ''}${props.genealogy.location}${props.genealogy.memberCount} 位成员${props.genealogy.createTime ? `,创建于 ${formatGenealogyDate(props.genealogy.createTime)}` : ''}`,
);
</script>
<style scoped lang="scss">
.genealogy-card {
--card-padding-x: 24rpx;
--card-padding-y: 20rpx;
display: grid;
grid-template-columns: 112rpx minmax(0, 1fr);
min-height: 204rpx;
align-items: center;
column-gap: 20rpx;
padding: 20rpx 24rpx;
box-sizing: border-box;
background: transparent;
}
.genealogy-card--current {
background: transparent;
}
.genealogy-card--pressed {
opacity: 0.72;
}
.row-frame {
grid-area: 1 / 1 / 2 / -1;
z-index: 1;
width: calc(100% + var(--card-padding-x) + var(--card-padding-x));
height: calc(100% + var(--card-padding-y) + var(--card-padding-y));
margin: calc(-1 * var(--card-padding-y)) calc(-1 * var(--card-padding-x));
pointer-events: none;
}
.genealogy-book {
position: relative;
grid-column: 1;
grid-row: 1;
z-index: 2;
width: 112rpx;
height: 153rpx;
}
.genealogy-book__cover {
display: block;
width: 100%;
height: 100%;
}
.genealogy-book__spine-title {
position: absolute;
top: 5rpx;
left: 7rpx;
display: flex;
width: 23rpx;
height: 91rpx;
align-items: center;
justify-content: center;
flex-direction: column;
color: #2f2925;
font-family: "STKaiti", "KaiTi", "SimSun", serif;
}
.genealogy-book__spine-character {
display: block;
font-size: clamp(9.5px, 17rpx, 12px);
font-weight: 400;
line-height: 1.05;
}
.card-main {
grid-column: 2;
grid-row: 1;
z-index: 2;
display: flex;
min-width: 0;
flex: 1;
flex-direction: column;
}
.card-title-row {
display: flex;
min-width: 0;
align-items: center;
justify-content: space-between;
gap: 12rpx;
}
.card-name {
min-width: 0;
flex: 1;
color: $ink;
font-family: "STKaiti", "KaiTi", serif;
font-size: clamp(18px, 34rpx, 23px);
font-weight: 700;
line-height: 1.25;
overflow: hidden;
overflow-wrap: anywhere;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
.card-detail-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
min-width: 0;
align-items: center;
column-gap: 16rpx;
row-gap: 4rpx;
margin-top: 10rpx;
}
.card-meta-item {
display: flex;
min-width: 0;
align-items: center;
}
.card-meta-item--location {
grid-column: 1 / -1;
}
.card-meta-item--members {
grid-column: 1;
}
.card-meta-icon {
width: 38rpx;
height: 38rpx;
flex: 0 0 auto;
margin-right: 6rpx;
opacity: 1;
filter: saturate(1.35) brightness(0.82) contrast(1.15);
}
.card-meta {
min-width: 0;
color: #62584c;
font-size: clamp(15px, 24rpx, 18px);
font-weight: 500;
overflow-wrap: anywhere;
}
.card-trailing {
grid-column: 2;
display: flex;
min-width: 0;
align-items: flex-end;
justify-content: center;
row-gap: 4rpx;
flex-direction: column;
}
.card-updated {
display: flex;
align-items: center;
justify-content: flex-end;
margin-top: 0;
margin-left: 0;
color: #62584c;
font-size: clamp(14px, 22rpx, 17px);
font-weight: 500;
line-height: 1.35;
white-space: nowrap;
overflow-wrap: anywhere;
}
.card-role {
min-width: 0;
padding: 3rpx 10rpx 4rpx;
border: 1rpx solid rgba(181, 138, 75, 0.56);
border-radius: 999rpx;
color: #7f4f16;
font-size: clamp(14px, 23rpx, 17px);
font-weight: 600;
line-height: 1.25;
white-space: nowrap;
}
.card-chevron {
width: 30rpx;
height: 30rpx;
margin-left: 8rpx;
}
@media screen and (max-width: 340px) {
.genealogy-card {
--card-padding-x: 18rpx;
grid-template-columns: 100rpx minmax(0, 1fr);
column-gap: 16rpx;
min-height: 176rpx;
padding-right: 18rpx;
padding-left: 18rpx;
}
.genealogy-book {
width: 100rpx;
height: 136rpx;
}
.genealogy-book__spine-title {
top: 4rpx;
left: 6rpx;
width: 21rpx;
height: 81rpx;
}
.card-name {
font-size: clamp(17px, 32rpx, 22px);
}
.card-meta {
font-size: clamp(14px, 22rpx, 17px);
}
.card-updated {
display: none;
}
.card-chevron {
width: 28rpx;
height: 28rpx;
margin-left: 8rpx;
}
}
</style>
@@ -0,0 +1,89 @@
<template>
<view class="home-advertisement-panel">
<view class="home-advertisement-panel__grid">
<button
v-for="advertisement in advertisements"
:key="advertisement.image"
class="home-advertisement-panel__card"
:aria-label="`${advertisement.title},查看更多内容`"
@click="emit('open')"
>
<image :src="advertisement.image" mode="aspectFill" />
</button>
</view>
<button class="home-advertisement-panel__more" @click="emit('open')">
点击查看更多内容
</button>
</view>
</template>
<script setup>
const emit = defineEmits(["open"]);
const advertisements = Object.freeze([
{
title: "寻根问祖",
image: "/static/assets/modules/genealogy/opaque/home-ad-heritage-hall.png",
},
{
title: "家族传承",
image: "/static/assets/modules/genealogy/opaque/home-ad-family-tree.png",
},
]);
</script>
<style scoped lang="scss">
.home-advertisement-panel {
min-height: 176rpx;
padding: 10rpx 8rpx 4rpx;
border-top: 1rpx solid rgba(154, 111, 56, 0.34);
background: rgba(255, 253, 249, 0.96);
box-sizing: border-box;
}
.home-advertisement-panel__grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12rpx;
}
.home-advertisement-panel__card,
.home-advertisement-panel__more {
margin: 0;
padding: 0;
border: 0;
background: transparent;
}
.home-advertisement-panel__card::after,
.home-advertisement-panel__more::after {
border: 0;
}
.home-advertisement-panel__card {
height: 112rpx;
overflow: hidden;
border: 1rpx solid rgba(142, 96, 44, 0.3);
border-radius: 10rpx;
background: #f1ece2;
line-height: normal;
}
.home-advertisement-panel__card image {
display: block;
width: 100%;
height: 100%;
}
.home-advertisement-panel__more {
display: block;
width: 100%;
height: 40rpx;
color: $brand-red;
font-family: "STKaiti", "KaiTi", serif;
font-size: clamp(13px, 22rpx, 16px);
font-weight: 700;
line-height: 40rpx;
text-align: center;
}
</style>
+410
View File
@@ -0,0 +1,410 @@
<template>
<AppDialog
:visible="managerVisible"
:close-on-mask="false"
eyebrow="家谱邀请"
title="邀请家人加入"
confirm-text="生成新邀请码"
cancel-text="关闭"
show-cancel
@confirm="requestIssueInvitation"
@cancel="closeManager"
>
<text class="invitation-manager__note">
邀请码只会显示一次请及时发送给家人有效期和使用状态以页面提示为准
</text>
<AppLoading v-if="invitationState === 'loading'" text="正在加载邀请记录" />
<view v-else-if="invitationState === 'error'" class="invitation-manager__state">
<text>{{ invitationError || "邀请记录暂时无法加载。" }}</text>
<AppButton compact type="secondary" label="重新加载" @click="loadInvitations" />
</view>
<view v-else-if="invitations.length" class="invitation-manager__list">
<view
v-for="invitation in invitations"
:key="invitation.id"
class="invitation-manager__row"
>
<view>
<text>{{ invitation.genealogyName }}</text>
<text>有效至 {{ formatInvitationTime(invitation.expiresAt) }}</text>
<text>{{ invitationStatusLabel(invitation.status) }}</text>
</view>
<AppButton
v-if="invitation.status === 'ACTIVE'"
compact
type="secondary"
label="撤销"
:disabled="revokingInvitationId === invitation.id"
@click="requestRevokeInvitation(invitation)"
/>
</view>
</view>
<view v-else class="invitation-manager__state">
<text>还没有发出过邀请码</text>
</view>
</AppDialog>
<AppDialog
:visible="issueConfirmationVisible"
:close-on-mask="false"
eyebrow="生成邀请码"
title="生成新的邀请码?"
message="生成后请只发送给要邀请的家人。邀请码是否有效,以页面提示为准。"
:confirm-text="issuingInvitation ? '正在生成' : '确认生成'"
cancel-text="暂不生成"
show-cancel
@confirm="issueInvitation"
@cancel="closeIssueConfirmation"
/>
<AppDialog
:visible="issuedInvitationVisible"
:close-on-mask="false"
eyebrow="邀请码已生成"
title="请立即保存并发送"
:message="issuedInvitationMessage"
confirm-text="我已保存"
:show-cancel="false"
@confirm="closeIssuedInvitation"
@cancel="closeIssuedInvitation"
>
<text v-if="issuedInvitation" class="issued-invitation__token" selectable>
{{ issuedInvitation.token }}
</text>
<text v-if="copyNotice" class="issued-invitation__notice">{{ copyNotice }}</text>
<AppButton
v-if="issuedInvitation"
block
type="secondary"
label="复制邀请码"
@click="copyIssuedInvitation"
/>
</AppDialog>
<AppDialog
:visible="revokeConfirmationVisible"
:close-on-mask="false"
eyebrow="撤销确认"
title="撤销这个邀请码?"
:message="revokeConfirmationMessage"
:confirm-text="revokingInvitationId ? '正在撤销' : '确认撤销'"
cancel-text="暂不撤销"
show-cancel
@confirm="revokeInvitation"
@cancel="closeRevokeConfirmation"
/>
</template>
<script setup>
import { computed, onUnmounted, ref, watch } from "vue";
import AppButton from "@/components/AppButton.vue";
import AppDialog from "@/components/AppDialog.vue";
import AppLoading from "@/components/AppLoading.vue";
import {
createRequestController,
isRequestCancelled
} from "@/services/api/request-controller.js";
import { genealogyMembershipApi } from "@/services/api/genealogy-membership-service.js";
import { getRequestErrorMessage } from "@/services/api/request-error-message.js";
import { createNonIdempotentWriteGuard } from "@/utils/non-idempotent-write-guard.js";
const props = defineProps({
genealogyId: {
type: String,
required: true,
},
});
const emit = defineEmits(["busy-change", "transient-change"]);
const managerVisible = ref(false);
const invitationState = ref("idle");
const invitationError = ref("");
const invitations = ref([]);
const issueConfirmationVisible = ref(false);
const issuingInvitation = ref(false);
const issuedInvitationVisible = ref(false);
const issuedInvitation = ref(null);
const copyNotice = ref("");
const revokeConfirmationVisible = ref(false);
const revokeTarget = ref(null);
const revokingInvitationId = ref("");
const invitationListRequestController = createRequestController();
const invitationIssuanceRequestController = createRequestController();
const invitationRevocationRequestController = createRequestController();
const invitationIssuanceGuard = createNonIdempotentWriteGuard();
let componentActive = true;
const isBusy = computed(() =>
issuingInvitation.value || Boolean(revokingInvitationId.value),
);
const hasTransient = computed(() =>
managerVisible.value ||
issueConfirmationVisible.value ||
issuedInvitationVisible.value ||
revokeConfirmationVisible.value,
);
const issuedInvitationMessage = computed(() =>
issuedInvitation.value
? `适用于「${issuedInvitation.value.genealogyName}」,有效至 ${formatInvitationTime(
issuedInvitation.value.expiresAt,
)}。关闭后无法再次查看原始邀请码。`
: "",
);
const revokeConfirmationMessage = computed(() =>
revokeTarget.value
? `撤销后「${revokeTarget.value.genealogyName}」的邀请码将不能再被使用。`
: "",
);
watch(isBusy, (busy) => emit("busy-change", busy), { immediate: true });
watch(hasTransient, (visible) => emit("transient-change", visible), {
immediate: true,
});
const formatInvitationTime = (value) =>
typeof value === "string" && value.length >= 10 ? value.slice(0, 10) : value;
const invitationStatusLabel = (status) =>
({
ACTIVE: "等待使用",
REDEEMED: "已被使用",
REVOKED: "已撤销",
EXPIRED: "已过期",
})[status] || "状态已更新";
const loadInvitations = async () => {
if (!props.genealogyId) return;
invitationListRequestController.abort();
invitationState.value = "loading";
invitationError.value = "";
try {
const invitationRows = await genealogyMembershipApi.getMyGenealogyInvitations({
requestController: invitationListRequestController,
});
if (!componentActive) return;
invitations.value = invitationRows.filter(
(invitation) => invitation.genealogyId === props.genealogyId,
);
invitationState.value = "ready";
} catch (error) {
if (!componentActive || isRequestCancelled(error)) return;
invitationState.value = "error";
invitationError.value = getRequestErrorMessage(
error,
"邀请记录加载失败,请稍后重试。",
);
}
};
const open = () => {
if (!props.genealogyId) return false;
managerVisible.value = true;
void loadInvitations();
return true;
};
const closeManager = () => {
if (isBusy.value) return false;
managerVisible.value = false;
return true;
};
const requestIssueInvitation = () => {
if (invitationState.value === "loading" || isBusy.value) return;
issueConfirmationVisible.value = true;
};
const closeIssueConfirmation = () => {
if (isBusy.value) return false;
issueConfirmationVisible.value = false;
return true;
};
const issueInvitation = async () => {
if (!props.genealogyId || isBusy.value) return;
const issueAttempt = invitationIssuanceGuard.begin({
genealogyId: props.genealogyId,
});
if (issueAttempt === null) {
managerVisible.value = true;
invitationError.value =
"上次生成结果暂时无法确认,请先检查邀请记录,避免重复生成。";
return;
}
issuingInvitation.value = true;
issueConfirmationVisible.value = false;
managerVisible.value = false;
copyNotice.value = "";
try {
const invitation = await genealogyMembershipApi.issueGenealogyInvitation(
props.genealogyId,
{ requestController: invitationIssuanceRequestController },
);
if (!componentActive) return;
issuedInvitation.value = invitation;
issuedInvitationVisible.value = true;
} catch (error) {
if (!componentActive) return;
if (invitationIssuanceGuard.recordFailure(issueAttempt, error)) {
managerVisible.value = true;
invitationError.value =
"邀请码生成结果暂时无法确认,请先检查邀请记录,避免重复生成。";
return;
}
if (isRequestCancelled(error)) return;
invitationError.value = getRequestErrorMessage(
error,
"邀请码生成失败,请稍后重试。",
);
invitationState.value = "error";
managerVisible.value = true;
} finally {
if (componentActive) issuingInvitation.value = false;
}
};
const closeIssuedInvitation = () => {
if (isBusy.value) return false;
issuedInvitationVisible.value = false;
issuedInvitation.value = null;
copyNotice.value = "";
return true;
};
const copyIssuedInvitation = () => {
const invitationToken = issuedInvitation.value?.token;
if (!invitationToken) return;
if (typeof uni === "undefined" || typeof uni.setClipboardData !== "function") {
copyNotice.value = "暂时无法自动复制,请手动保存邀请码。";
return;
}
uni.setClipboardData({
data: invitationToken,
success: () => {
copyNotice.value = "邀请码已复制,请发送给家人。";
},
fail: () => {
copyNotice.value = "复制失败,请手动保存邀请码。";
},
});
};
const requestRevokeInvitation = (invitation) => {
if (!invitation || invitation.status !== "ACTIVE" || isBusy.value) return;
revokeTarget.value = invitation;
revokeConfirmationVisible.value = true;
};
const closeRevokeConfirmation = () => {
if (isBusy.value) return false;
revokeConfirmationVisible.value = false;
revokeTarget.value = null;
return true;
};
const revokeInvitation = async () => {
const invitation = revokeTarget.value;
if (!invitation || invitation.status !== "ACTIVE" || isBusy.value) return;
revokingInvitationId.value = invitation.id;
invitationError.value = "";
try {
await genealogyMembershipApi.revokeGenealogyInvitation(invitation.id, {
requestController: invitationRevocationRequestController,
});
if (!componentActive) return;
revokeConfirmationVisible.value = false;
revokeTarget.value = null;
await loadInvitations();
} catch (error) {
if (!componentActive || isRequestCancelled(error)) return;
invitationError.value = getRequestErrorMessage(
error,
"邀请码撤销失败,请稍后重试。",
);
revokeConfirmationVisible.value = false;
revokeTarget.value = null;
} finally {
if (componentActive) revokingInvitationId.value = "";
}
};
const closeTransient = () => {
if (isBusy.value) return true;
if (revokeConfirmationVisible.value) return closeRevokeConfirmation();
if (issuedInvitationVisible.value) return closeIssuedInvitation();
if (issueConfirmationVisible.value) return closeIssueConfirmation();
if (managerVisible.value) return closeManager();
return false;
};
defineExpose({ closeTransient, open });
onUnmounted(() => {
componentActive = false;
invitationListRequestController.abort();
invitationIssuanceRequestController.abort();
invitationRevocationRequestController.abort();
});
</script>
<style scoped lang="scss">
.invitation-manager__note,
.invitation-manager__state text,
.invitation-manager__row text,
.issued-invitation__token,
.issued-invitation__notice {
display: block;
}
.invitation-manager__note,
.invitation-manager__state text,
.invitation-manager__row text:not(:first-child),
.issued-invitation__notice {
color: $ink-muted;
font-size: clamp(14px, 22rpx, 17px);
line-height: 1.55;
}
.invitation-manager__state,
.invitation-manager__list {
width: 100%;
margin-top: 20rpx;
}
.invitation-manager__state .app-button {
margin-top: 14rpx;
}
.invitation-manager__row {
display: flex;
width: 100%;
align-items: center;
justify-content: space-between;
padding: 16rpx 0;
border-top: 1rpx solid rgba(152, 119, 72, 0.28);
text-align: left;
gap: 18rpx;
}
.invitation-manager__row > view {
min-width: 0;
flex: 1;
}
.invitation-manager__row .app-button {
width: auto;
min-width: 120rpx;
flex: 0 0 auto;
}
.invitation-manager__row text:first-child {
color: $ink;
font-family: "STKaiti", "KaiTi", serif;
font-size: clamp(16px, 28rpx, 21px);
font-weight: 700;
overflow-wrap: break-word;
word-break: break-word;
}
.issued-invitation__token {
box-sizing: border-box;
width: 100%;
margin-top: 18rpx;
padding: 18rpx;
border: 1rpx dashed rgba(159, 23, 15, 0.48);
border-radius: 8rpx;
color: $brand-red;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: clamp(14px, 22rpx, 17px);
line-height: 1.5;
overflow-wrap: anywhere;
}
.issued-invitation__notice {
margin-top: 12rpx;
text-align: center;
}
.issued-invitation__token + .app-button {
margin-top: 18rpx;
}
</style>
+339
View File
@@ -0,0 +1,339 @@
<template>
<view v-if="visible" class="genealogy-order-layer" @click="requestClose">
<view
class="genealogy-order-dialog"
role="dialog"
aria-modal="true"
aria-label="调整家谱排序"
@click.stop
>
<view class="genealogy-order-dialog__head">
<view>
<text class="dialog-title">调整家谱排序</text>
<text class="genealogy-order-dialog__copy">保存后将按此顺序显示我的家谱</text>
</view>
<view
class="genealogy-order-dialog__close"
role="button"
aria-label="关闭排序"
hover-class="action-hover"
@click="requestClose"
>
<image
class="genealogy-order-dialog__close-icon"
src="/static/assets/modules/genealogy/transparent/dialog-close.png"
mode="aspectFit"
/>
</view>
</view>
<scroll-view class="genealogy-order-list" scroll-y>
<view
v-for="(genealogy, index) in orderDraft"
:key="genealogy.id"
class="genealogy-order-item"
>
<view class="genealogy-order-item__main">
<text class="genealogy-order-item__position">{{ index + 1 }}</text>
<view>
<text class="genealogy-order-item__name">{{ genealogy.name }}</text>
<text class="genealogy-order-item__role">
{{ genealogy.canManage ? "管理员" : "成员" }}
</text>
</view>
</view>
<view class="genealogy-order-item__actions">
<button
class="genealogy-order-item__move"
:disabled="saving || index === 0"
@click="moveOrderItem(index, -1)"
>上移</button>
<button
class="genealogy-order-item__move"
:disabled="saving || index === orderDraft.length - 1"
@click="moveOrderItem(index, 1)"
>下移</button>
</view>
</view>
</scroll-view>
<text v-if="orderError" class="genealogy-order-error">{{ orderError }}</text>
<view class="genealogy-order-dialog__actions">
<AppButton type="secondary" :disabled="saving" label="取消" @click="requestClose" />
<AppButton
:disabled="saving || !isOrderDirty"
:label="saving ? '正在保存' : '保存排序'"
@click="saveOrder"
/>
</view>
</view>
</view>
</template>
<script setup>
import { computed, onUnmounted, ref, watch } from "vue";
import AppButton from "@/components/AppButton.vue";
import {
createRequestController,
isRequestCancelled
} from "@/services/api/request-controller.js";
import { genealogyApi } from "@/services/api/genealogy-service.js";
import { getRequestErrorMessage } from "@/services/api/request-error-message.js";
const props = defineProps({
visible: { type: Boolean, required: true },
genealogies: { type: Array, required: true },
});
const emit = defineEmits(["close", "saved"]);
const orderDraft = ref([]);
const saving = ref(false);
const orderError = ref("");
const orderSaveRequestController = createRequestController();
let componentActive = true;
const isOrderDirty = computed(() =>
orderDraft.value.some(
(genealogy, index) => genealogy.id !== props.genealogies[index]?.id,
),
);
watch(
() => props.visible,
(visible) => {
if (!visible) return;
orderDraft.value = props.genealogies.map((genealogy) => ({ ...genealogy }));
orderError.value = "";
},
{ immediate: true },
);
const requestClose = () => {
if (saving.value) return false;
orderError.value = "";
emit("close");
return true;
};
const moveOrderItem = (sourceIndex, direction) => {
const targetIndex = sourceIndex + direction;
if (
saving.value ||
targetIndex < 0 ||
targetIndex >= orderDraft.value.length
) {
return;
}
const nextOrder = [...orderDraft.value];
[nextOrder[sourceIndex], nextOrder[targetIndex]] = [
nextOrder[targetIndex],
nextOrder[sourceIndex],
];
orderDraft.value = nextOrder;
orderError.value = "";
};
const saveOrder = async () => {
if (saving.value || !isOrderDirty.value) return;
const draftIds = orderDraft.value.map((genealogy) => String(genealogy.id));
const currentIds = props.genealogies.map((genealogy) => String(genealogy.id));
if (
draftIds.length !== currentIds.length ||
new Set(draftIds).size !== draftIds.length ||
draftIds.some((id) => !currentIds.includes(id))
) {
orderError.value = "当前家谱列表已变化,请关闭后重新调整。";
return;
}
saving.value = true;
orderError.value = "";
try {
const confirmedGenealogies = await genealogyApi.saveMyGenealogyOrder(draftIds, {
requestController: orderSaveRequestController,
});
if (!componentActive) return;
emit("saved", confirmedGenealogies);
} catch (error) {
if (componentActive && !isRequestCancelled(error)) {
orderError.value = getRequestErrorMessage(error, "保存排序失败,请稍后重试。");
}
} finally {
if (componentActive) saving.value = false;
}
};
onUnmounted(() => {
componentActive = false;
orderSaveRequestController.abort();
});
defineExpose({ requestClose });
</script>
<style scoped lang="scss">
.genealogy-order-layer {
position: fixed;
z-index: 40;
inset: 0;
display: flex;
align-items: flex-end;
justify-content: center;
padding: 32rpx;
background: rgba(36, 24, 16, 0.54);
box-sizing: border-box;
}
.genealogy-order-dialog {
width: 100%;
max-width: 680rpx;
max-height: 80vh;
padding: 32rpx;
border: 2rpx solid rgba(128, 78, 29, 0.28);
border-radius: 24rpx;
background: #f9f6ef;
box-sizing: border-box;
}
.genealogy-order-dialog__head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 20rpx;
}
.dialog-title {
color: $brand-red;
font-family: "STKaiti", "KaiTi", serif;
font-size: clamp(22px, 42rpx, 28px);
font-weight: 700;
letter-spacing: 4rpx;
}
.genealogy-order-dialog__copy,
.genealogy-order-item__name,
.genealogy-order-item__role,
.genealogy-order-error {
display: block;
}
.genealogy-order-dialog__copy,
.genealogy-order-item__role {
margin-top: 8rpx;
color: #8a7564;
font-size: clamp(14px, 23rpx, 17px);
}
.genealogy-order-dialog__close {
display: flex;
width: 80rpx;
height: 80rpx;
flex: 0 0 auto;
align-items: center;
justify-content: center;
margin-top: -42rpx;
margin-right: -24rpx;
}
.genealogy-order-dialog__close-icon {
width: 80rpx;
height: 80rpx;
}
.genealogy-order-list {
max-height: 720rpx;
margin-top: 28rpx;
}
.genealogy-order-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16rpx;
min-height: 108rpx;
padding: 16rpx 0;
border-bottom: 1rpx solid rgba(149, 103, 49, 0.16);
}
.genealogy-order-item__main,
.genealogy-order-item__actions {
display: flex;
align-items: center;
}
.genealogy-order-item__main {
min-width: 0;
gap: 18rpx;
}
.genealogy-order-item__main > view {
min-width: 0;
flex: 1;
}
.genealogy-order-item__position {
display: grid;
width: 42rpx;
height: 42rpx;
flex: 0 0 auto;
place-items: center;
border-radius: 50%;
background: #7b4024;
color: #fffaf0;
font-size: clamp(14px, 23rpx, 17px);
}
.genealogy-order-item__name {
color: #3e2b20;
font-size: clamp(16px, 27rpx, 20px);
font-weight: 700;
line-height: 1.35;
overflow-wrap: anywhere;
}
.genealogy-order-item__actions {
flex: 0 0 auto;
gap: 10rpx;
}
.genealogy-order-item__move {
min-width: 82rpx;
height: 54rpx;
margin: 0;
padding: 0 14rpx;
border: 1rpx solid rgba(123, 64, 36, 0.46);
border-radius: 8rpx;
background: transparent;
color: #7b4024;
font-size: clamp(14px, 23rpx, 17px);
line-height: 52rpx;
}
.genealogy-order-item__move::after {
border: 0;
}
.genealogy-order-item__move[disabled] {
opacity: 0.36;
}
.genealogy-order-error {
margin-top: 20rpx;
color: #b3432f;
font-size: clamp(15px, 24rpx, 18px);
line-height: 1.5;
}
.genealogy-order-dialog__actions {
display: flex;
justify-content: flex-end;
gap: 18rpx;
margin-top: 28rpx;
}
.genealogy-order-dialog__actions .app-button {
width: 220rpx;
}
.action-hover {
opacity: 0.82;
}
</style>
+49
View File
@@ -0,0 +1,49 @@
<template>
<view
class="genealogy-page-background"
:class="{ 'genealogy-page-background--light': tone === 'light' }"
>
<image
class="genealogy-page-background__art"
src="/static/assets/modules/genealogy/opaque/genealogy-page-background-long.png"
mode="aspectFill"
/>
</view>
</template>
<script setup>
defineProps({
tone: { type: String, default: "default" },
});
</script>
<style scoped>
.genealogy-page-background {
position: fixed;
inset: 0;
z-index: 0;
overflow: hidden;
background: #e7ded1;
pointer-events: none;
}
.genealogy-page-background__art {
position: absolute;
bottom: 0;
right: 0;
left: 0;
display: block;
width: 100%;
height: 100%;
opacity: 0.28;
}
.genealogy-page-background--light {
background: #faf8f3;
}
.genealogy-page-background--light .genealogy-page-background__art {
opacity: 0.15;
}
</style>
+208
View File
@@ -0,0 +1,208 @@
<template>
<RegionPickerSheet
:visible="visible"
:columns="columns"
:indexes="indexes"
:indicator-style="indicatorStyle"
:close-on-mask="closeOnMask"
@change="changeSelection"
@cancel="close"
@confirm="confirmSelection"
/>
</template>
<script setup>
import { onUnmounted, ref } from "vue";
import RegionPickerSheet from "@/components/genealogy/RegionPickerSheet.vue";
import {
createRequestController,
isRequestCancelled
} from "@/services/api/request-controller.js";
import { regionApi } from "@/services/api/region-service.js";
import { getRequestErrorMessage } from "@/services/api/request-error-message.js";
const props = defineProps({
closeOnMask: {
type: Boolean,
default: false,
},
maxLevels: {
type: Number,
default: 5,
validator: (levelCount) => Number.isInteger(levelCount) && levelCount > 0,
},
});
const emit = defineEmits([
"error-change",
"loading-change",
"select",
"transient-change",
]);
const indicatorStyle =
"height: 104rpx; border-top: 1px solid rgba(159, 23, 15, .46); border-bottom: 1px solid rgba(159, 23, 15, .46); background: rgba(159, 23, 15, .08);";
const visible = ref(false);
const columns = ref([]);
const indexes = ref([0]);
const selectedTrail = ref([]);
const preparedRegionCode = ref("");
const regionRequestController = createRequestController();
let componentActive = true;
let loading = false;
const setLoading = (nextLoading) => {
loading = nextLoading;
emit("loading-change", nextLoading);
};
const setError = (message = "") => {
emit("error-change", message);
};
const fetchRegionChildren = (parentCode) =>
regionApi.getRegionChildren(parentCode, {
requestController: regionRequestController,
});
const loadColumns = async (requestedIndexes = []) => {
const rootOptions = columns.value[0] || (await fetchRegionChildren("0"));
if (!rootOptions.length) return false;
const nextColumns = [rootOptions];
const nextIndexes = [];
let options = rootOptions;
for (let level = 0; level < props.maxLevels; level += 1) {
const requestedIndex = Number(requestedIndexes[level]);
const selectedIndex = Number.isInteger(requestedIndex)
? Math.min(Math.max(requestedIndex, 0), options.length - 1)
: 0;
const selectedOption = options[selectedIndex];
if (!selectedOption) break;
nextIndexes.push(selectedIndex);
if (level === props.maxLevels - 1) break;
const childOptions = await fetchRegionChildren(selectedOption.regionCode);
if (!childOptions.length) break;
nextColumns.push(childOptions);
options = childOptions;
}
if (!componentActive) return false;
columns.value = nextColumns;
indexes.value = nextIndexes;
selectedTrail.value = nextColumns
.map((column, level) => column[nextIndexes[level]])
.filter(Boolean);
return Boolean(selectedTrail.value.length);
};
const loadRegionPath = async (regionCode) => {
const regionPath = (await regionApi.getRegionPath(regionCode, {
requestController: regionRequestController,
})).filter((regionNode) => regionNode?.regionCode);
if (
!regionPath.length ||
regionPath[regionPath.length - 1].regionCode !== regionCode
) {
throw new Error("REGION_PATH_MISMATCH");
}
const nextColumns = [];
const nextIndexes = [];
const nextTrail = [];
let parentCode = "0";
for (const regionPathNode of regionPath.slice(0, props.maxLevels)) {
const options = await fetchRegionChildren(parentCode);
const selectedIndex = options.findIndex(
(option) => option.regionCode === regionPathNode.regionCode,
);
if (selectedIndex < 0) throw new Error("REGION_PATH_MISMATCH");
nextColumns.push(options);
nextIndexes.push(selectedIndex);
nextTrail.push(options[selectedIndex]);
parentCode = regionPathNode.regionCode;
}
if (!componentActive) return false;
columns.value = nextColumns;
indexes.value = nextIndexes;
selectedTrail.value = nextTrail;
return Boolean(nextTrail.length);
};
const prepare = async (initialRegionCode = "") => {
const normalizedRegionCode = String(initialRegionCode || "");
if (loading) return false;
if (
columns.value.length &&
preparedRegionCode.value === normalizedRegionCode
) {
return true;
}
regionRequestController.abort();
setLoading(true);
setError();
try {
const ready = normalizedRegionCode
? await loadRegionPath(normalizedRegionCode)
: await loadColumns([0]);
if (!componentActive || !ready) return false;
preparedRegionCode.value = normalizedRegionCode;
return true;
} catch (error) {
if (!componentActive || isRequestCancelled(error)) return false;
const fallback = normalizedRegionCode
? "暂时无法定位当前地区,请稍后重试。"
: "地区列表加载失败,请重试";
setError(getRequestErrorMessage(error, fallback));
return false;
} finally {
if (componentActive) setLoading(false);
}
};
const open = async (initialRegionCode = "") => {
if (!(await prepare(initialRegionCode))) return false;
visible.value = true;
emit("transient-change", true);
return true;
};
const close = () => {
visible.value = false;
emit("transient-change", false);
};
const changeSelection = async (event) => {
if (loading) return;
const requestedIndexes = (event?.detail?.value || []).map(
(index) => Number(index) || 0,
);
const changedLevel = requestedIndexes.findIndex(
(index, level) => index !== (indexes.value[level] || 0),
);
if (changedLevel < 0) return;
setLoading(true);
setError();
try {
const loaded = await loadColumns(requestedIndexes.slice(0, changedLevel + 1));
if (!componentActive || !loaded) return;
preparedRegionCode.value = "";
} catch (error) {
if (!componentActive || isRequestCancelled(error)) return;
setError(getRequestErrorMessage(error, "地区列表加载失败,请重试"));
} finally {
if (componentActive) setLoading(false);
}
};
const confirmSelection = () => {
const trail = columns.value
.map((column, level) => column[Number(indexes.value[level])])
.filter(Boolean);
const region = trail[trail.length - 1];
if (!region) return;
selectedTrail.value = trail;
preparedRegionCode.value = region.regionCode;
emit("select", { region, trail });
close();
};
defineExpose({ close, open, prepare });
onUnmounted(() => {
componentActive = false;
regionRequestController.abort();
});
</script>
+187
View File
@@ -0,0 +1,187 @@
<template>
<view v-if="visible" class="region-sheet">
<view class="region-sheet__mask" @click="requestMaskClose" />
<view class="region-sheet__panel">
<view class="region-sheet__intro">
<text class="region-sheet__title">选择地区</text>
</view>
<view class="region-sheet__picker">
<view class="region-sheet__column-headings">
<text
v-for="label in columnLabels"
:key="label"
class="region-sheet__column-heading"
>
{{ label }}
</text>
</view>
<picker-view
class="region-sheet__picker-view"
:indicator-style="indicatorStyle"
:value="indexes"
@change="$emit('change', $event)"
>
<picker-view-column
v-for="(column, columnIndex) in columns"
:key="columnIndex"
>
<view
v-for="(option, optionIndex) in column"
:key="option.regionCode"
class="region-sheet__picker-item"
:class="{
'region-sheet__picker-item--selected':
indexes[columnIndex] === optionIndex,
}"
>
{{ option.label }}
</view>
</picker-view-column>
</picker-view>
</view>
<view class="region-sheet__footer">
<view class="region-sheet__cancel" @click="$emit('cancel')">取消</view>
<button class="region-sheet__confirm" @click="$emit('confirm')">
确认选择
</button>
</view>
</view>
</view>
</template>
<script setup>
import { computed } from "vue";
const props = defineProps({
visible: { type: Boolean, default: false },
columns: { type: Array, default: () => [] },
indexes: { type: Array, default: () => [] },
indicatorStyle: { type: String, required: true },
closeOnMask: { type: Boolean, default: false },
});
const emit = defineEmits(["change", "cancel", "confirm"]);
const regionLevelLabels = Object.freeze([
"省份",
"城市",
"区县",
"乡镇街道",
"村社区",
]);
const columnLabels = computed(() =>
regionLevelLabels.slice(0, props.columns.length),
);
const requestMaskClose = () => {
if (props.closeOnMask) emit("cancel");
};
</script>
<style scoped lang="scss">
.region-sheet {
position: fixed;
z-index: 10;
inset: 0;
display: flex;
align-items: flex-end;
}
.region-sheet__mask {
position: absolute;
inset: 0;
background: rgba(43, 30, 20, 0.42);
}
.region-sheet__panel {
position: relative;
width: 100%;
padding: 22rpx 28rpx calc(24rpx + env(safe-area-inset-bottom));
border-radius: 30rpx 30rpx 0 0;
background: #fdf9ef;
box-shadow: 0 -12rpx 36rpx rgba(43, 30, 20, 0.2);
}
.region-sheet__intro {
padding: 0 10rpx 16rpx;
text-align: center;
}
.region-sheet__title {
display: block;
color: $ink;
font-family: STKaiti, KaiTi, serif;
font-size: clamp(19px, 36rpx, 24px);
font-weight: 700;
}
.region-sheet__picker {
overflow: hidden;
border: 1rpx solid rgba(128, 89, 49, 0.28);
border-radius: 16rpx;
background: rgba(255, 252, 245, 0.8);
}
.region-sheet__column-headings {
display: flex;
height: 84rpx;
border-bottom: 1rpx solid rgba(128, 89, 49, 0.18);
}
.region-sheet__column-heading {
box-sizing: border-box;
width: 33.333%;
padding: 24rpx 12rpx;
color: $ink-muted;
font-size: clamp(16px, 26rpx, 20px);
text-align: center;
}
.region-sheet__column-heading + .region-sheet__column-heading {
border-left: 1rpx solid rgba(128, 89, 49, 0.16);
}
.region-sheet__picker-view {
width: 100%;
height: 520rpx;
}
.region-sheet__picker-item {
box-sizing: border-box;
height: 104rpx;
overflow: hidden;
padding: 0 6rpx;
color: $ink;
font-size: clamp(16px, 28rpx, 20px);
line-height: 104rpx;
text-align: center;
text-overflow: ellipsis;
white-space: nowrap;
}
.region-sheet__picker-item--selected {
color: $brand-red;
font-weight: 700;
}
.region-sheet__footer {
display: flex;
align-items: center;
margin-top: 22rpx;
gap: 12rpx;
}
.region-sheet__cancel {
min-width: 116rpx;
padding: 20rpx 10rpx;
color: $ink-muted;
font-size: clamp(16px, 28rpx, 20px);
text-align: center;
}
.region-sheet__confirm {
display: flex;
min-height: 82rpx;
flex: 1;
align-items: center;
justify-content: center;
margin: 0;
padding: 0;
border: 0;
border-radius: 10rpx;
background: $brand-red;
color: #fff;
font-size: clamp(16px, 29rpx, 20px);
font-weight: 700;
line-height: 1.4;
}
.region-sheet__confirm::after {
display: none;
}
</style>
+189
View File
@@ -0,0 +1,189 @@
<template>
<view v-if="visible" class="genealogy-switcher-layer" @click="emit('close')">
<view
class="genealogy-switcher"
role="dialog"
aria-modal="true"
aria-label="切换当前家谱"
@click.stop
>
<view class="genealogy-switcher__content">
<text class="dialog-title">切换当前家谱</text>
<view
class="genealogy-switcher__close"
role="button"
aria-label="关闭"
hover-class="action-hover"
@click="emit('close')"
>
<image
class="genealogy-switcher__close-icon"
src="/static/assets/modules/genealogy/transparent/dialog-close.png"
mode="aspectFit"
/>
</view>
<scroll-view class="genealogy-switcher__list" scroll-y>
<button
v-for="genealogy in genealogies"
:key="genealogy.id"
class="switcher-item"
:class="{ 'switcher-item--active': genealogy.id === selectedGenealogyId }"
:aria-pressed="genealogy.id === selectedGenealogyId"
:aria-label="`${genealogy.name}${genealogy.location}${genealogy.memberCount} 位成员`"
@click="emit('select', genealogy)"
>
<view>
<text class="switcher-item__name">{{ genealogy.name }}</text>
<text class="switcher-item__meta">
{{ genealogy.location }} · {{ genealogy.memberCount }} 位成员
</text>
</view>
<text class="switcher-item__state">
{{ genealogy.id === selectedGenealogyId ? "当前" : "选择" }}
</text>
</button>
</scroll-view>
</view>
</view>
</view>
</template>
<script setup>
defineProps({
visible: { type: Boolean, required: true },
genealogies: { type: Array, required: true },
selectedGenealogyId: { type: [String, Number], default: null },
});
const emit = defineEmits(["close", "select"]);
</script>
<style scoped lang="scss">
@use "../../styles/adaptive-frame-profiles.scss" as adaptive;
.genealogy-switcher-layer {
position: fixed;
z-index: 40;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
padding: 40rpx;
background: rgba(34, 20, 12, 0.58);
}
.genealogy-switcher {
@include adaptive.adaptive-genealogy-switcher;
width: 670rpx;
max-width: 100%;
min-height: 600rpx;
max-height: calc(100vh - 120rpx);
}
.genealogy-switcher__content {
display: grid;
min-height: 600rpx;
max-height: calc(100vh - 120rpx);
grid-template-rows: auto minmax(0, 1fr);
align-items: center;
box-sizing: border-box;
padding: 120rpx 58rpx 140rpx;
}
.genealogy-switcher__content > .dialog-title {
grid-area: 1 / 1;
}
.dialog-title {
color: $brand-red;
font-family: "STKaiti", "KaiTi", serif;
font-size: clamp(22px, 42rpx, 28px);
font-weight: 700;
letter-spacing: 4rpx;
}
.genealogy-switcher__close {
display: flex;
grid-area: 1 / 1;
align-self: start;
justify-self: end;
width: 80rpx;
height: 80rpx;
align-items: center;
justify-content: center;
margin-top: -42rpx;
margin-right: -24rpx;
}
.genealogy-switcher__close-icon {
width: 80rpx;
height: 80rpx;
}
.genealogy-switcher__list {
grid-area: 2 / 1;
width: 100%;
min-height: 0;
max-height: calc(100vh - 456rpx);
margin-top: 24rpx;
overflow-y: auto;
}
.switcher-item {
display: flex;
width: 100%;
min-height: 112rpx;
align-items: center;
justify-content: space-between;
box-sizing: border-box;
margin: 0;
padding: 18rpx 16rpx;
border: 1rpx solid transparent;
border-bottom-color: rgba(181, 138, 75, 0.42);
background: transparent;
line-height: normal;
text-align: left;
}
.switcher-item::after {
border: 0;
}
.switcher-item__name,
.switcher-item__meta {
display: block;
}
.switcher-item__name {
color: $ink;
font-family: "STKaiti", "KaiTi", serif;
font-size: clamp(17px, 32rpx, 22px);
font-weight: 700;
}
.switcher-item__meta {
margin-top: 6rpx;
color: #62584c;
font-size: clamp(15px, 24rpx, 18px);
}
.switcher-item__state {
color: $brand-red;
font-size: clamp(15px, 25rpx, 18px);
font-weight: 600;
}
.switcher-item--active {
border-color: rgba(159, 23, 15, 0.22);
background: rgba(159, 23, 15, 0.055);
}
.switcher-item--active .switcher-item__name {
color: $brand-red;
}
.action-hover {
opacity: 0.82;
}
</style>
@@ -0,0 +1,304 @@
<template>
<AppDialog
:visible="formVisible"
eyebrow="提现申请"
title="填写收款信息"
message="提交后将冻结对应收益,审核通过后按收款码转账。"
confirm-text="核对并提交"
cancel-text="暂不提现"
show-cancel
:close-on-mask="!submitting && !uploading"
@confirm="reviewWithdrawal"
@cancel="close"
>
<view class="withdrawal-form">
<text>提现金额</text>
<input
v-model.trim="withdrawalForm.amount"
type="digit"
maxlength="19"
placeholder="请输入提现金额"
/>
<text>收款人姓名</text>
<input
v-model.trim="withdrawalForm.payoutAccountName"
maxlength="64"
placeholder="请输入收款码对应姓名"
/>
<view
class="qr-picker"
role="button"
aria-label="选择收款码图片"
@click="choosePayoutQr"
>
<image
v-if="withdrawalForm.qrPreview"
:src="withdrawalForm.qrPreview"
mode="aspectFit"
/>
<text>{{ payoutQrPrompt }}</text>
</view>
<text v-if="withdrawalError" class="form-error">{{ withdrawalError }}</text>
</view>
</AppDialog>
<AppDialog
:visible="confirmationVisible"
title="确认申请提现吗?"
:message="confirmationMessage"
:confirm-text="submitting ? '正在提交' : '确认提交'"
cancel-text="返回修改"
show-cancel
:close-on-mask="false"
@confirm="submitWithdrawal"
@cancel="confirmationVisible = false"
/>
</template>
<script setup>
import { computed, onUnmounted, reactive, ref, watch } from "vue";
import AppDialog from "@/components/AppDialog.vue";
import {
createRequestController,
isRequestCancelled
} from "@/services/api/request-controller.js";
import { earningApi } from "@/services/api/earning-service.js";
import { getRequestErrorMessage } from "@/services/api/request-error-message.js";
import {
isImagePickCancelled,
pickAndUploadImage,
} from "@/utils/media-upload.js";
import { parseMoneyToCents } from "@/utils/profile/earning-money.js";
import { isWriteOutcomeUnknown } from "@/utils/request-outcome.js";
const props = defineProps({
availableAmount: {
type: String,
required: true,
},
minimumWithdrawal: {
type: String,
default: null,
},
afterSubmitted: {
type: Function,
required: true,
},
});
const emit = defineEmits(["busy-change", "transient-change"]);
const formVisible = ref(false);
const confirmationVisible = ref(false);
const withdrawalError = ref("");
const uploading = ref(false);
const submitting = ref(false);
const withdrawalForm = reactive({
requestId: "",
amount: "",
payoutAccountName: "",
payoutQrOssId: null,
qrPreview: "",
});
const payoutQrUploadRequestController = createRequestController();
const withdrawalSubmissionRequestController = createRequestController();
let componentActive = true;
const isBusy = computed(() => uploading.value || submitting.value);
const hasTransient = computed(() =>
formVisible.value || confirmationVisible.value,
);
const payoutQrPrompt = computed(() => {
if (uploading.value) return "正在上传收款码…";
return withdrawalForm.payoutQrOssId
? "重新选择收款码"
: "选择微信或支付宝收款码";
});
const confirmationMessage = computed(() =>
`本次申请 ¥${withdrawalForm.amount || "0.00"},收款人 ${
withdrawalForm.payoutAccountName || "未填写"
}。请确认金额和收款码无误。`,
);
watch(isBusy, (busy) => emit("busy-change", busy), { immediate: true });
watch(hasTransient, (visible) => emit("transient-change", visible), {
immediate: true,
});
const createWithdrawalRequestId = () =>
typeof globalThis.crypto?.randomUUID === "function"
? globalThis.crypto.randomUUID()
: `withdrawal-${Date.now()}-${Math.random().toString(16).slice(2)}`;
const validateWithdrawal = () => {
const amountInCents = parseMoneyToCents(withdrawalForm.amount);
const availableInCents = parseMoneyToCents(props.availableAmount);
const minimumInCents = parseMoneyToCents(props.minimumWithdrawal || "0.01");
if (amountInCents === null || amountInCents <= 0n) {
return "请输入正确的提现金额,最多保留两位小数。";
}
if (minimumInCents !== null && amountInCents < minimumInCents) {
return `提现金额不能低于 ¥${props.minimumWithdrawal || "0.01"}`;
}
if (availableInCents !== null && amountInCents > availableInCents) {
return "提现金额不能超过当前可用收益。";
}
if (!withdrawalForm.payoutAccountName.trim()) return "请填写收款人姓名。";
if (!withdrawalForm.payoutQrOssId) return "请选择收款码图片。";
return "";
};
const open = () => {
if (isBusy.value) return false;
Object.assign(withdrawalForm, {
requestId: createWithdrawalRequestId(),
amount: props.minimumWithdrawal || "",
payoutAccountName: "",
payoutQrOssId: null,
qrPreview: "",
});
withdrawalError.value = "";
formVisible.value = true;
return true;
};
const close = () => {
if (isBusy.value) return false;
formVisible.value = false;
confirmationVisible.value = false;
return true;
};
const closeTransient = () => {
if (confirmationVisible.value && !isBusy.value) {
confirmationVisible.value = false;
return true;
}
return formVisible.value ? close() : false;
};
const choosePayoutQr = async () => {
if (isBusy.value) return;
uploading.value = true;
withdrawalError.value = "";
try {
const payoutQrUpload = await pickAndUploadImage({
requestController: payoutQrUploadRequestController,
});
if (!componentActive) return;
withdrawalForm.payoutQrOssId = payoutQrUpload.ossId;
withdrawalForm.qrPreview =
payoutQrUpload.thumbnailUrl || payoutQrUpload.url;
} catch (error) {
if (
componentActive &&
!isImagePickCancelled(error) &&
!isRequestCancelled(error)
) {
withdrawalError.value = getRequestErrorMessage(
error,
"收款码上传失败,请重新选择。",
);
}
} finally {
if (componentActive) uploading.value = false;
}
};
const reviewWithdrawal = () => {
if (isBusy.value) return;
withdrawalError.value = validateWithdrawal();
if (!withdrawalError.value) confirmationVisible.value = true;
};
const submitWithdrawal = async () => {
if (submitting.value) return;
withdrawalError.value = validateWithdrawal();
if (withdrawalError.value) {
confirmationVisible.value = false;
return;
}
submitting.value = true;
let withdrawalCommitted = false;
try {
await earningApi.requestEarningWithdrawal(
{
requestId: withdrawalForm.requestId,
amount: withdrawalForm.amount,
payoutQrOssId: withdrawalForm.payoutQrOssId,
payoutAccountName: withdrawalForm.payoutAccountName,
},
{ requestController: withdrawalSubmissionRequestController },
);
withdrawalCommitted = true;
if (!componentActive) return;
confirmationVisible.value = false;
formVisible.value = false;
await props.afterSubmitted();
} catch (error) {
if (!componentActive) return;
confirmationVisible.value = false;
if (withdrawalCommitted) {
formVisible.value = false;
return;
}
if (isWriteOutcomeUnknown(error)) {
withdrawalError.value =
"暂时无法确认是否提交成功,请先关闭窗口查看提现记录,不要重复提交。";
return;
}
if (!isRequestCancelled(error))
withdrawalError.value = getRequestErrorMessage(
error,
"提现申请未提交,请核对后重试。",
);
} finally {
if (componentActive) submitting.value = false;
}
};
defineExpose({ closeTransient, open });
onUnmounted(() => {
componentActive = false;
payoutQrUploadRequestController.abort();
withdrawalSubmissionRequestController.abort();
});
</script>
<style scoped lang="scss">
.withdrawal-form {
width: 100%;
margin-top: 14rpx;
text-align: left;
}
.withdrawal-form > text {
display: block;
margin: 14rpx 0 7rpx;
color: $ink;
font-size: clamp(14px, 22rpx, 17px);
}
.withdrawal-form input {
box-sizing: border-box;
min-height: 76rpx;
padding: 0 18rpx;
border: 1rpx solid rgba(159, 35, 35, 0.25);
background: #fffdf7;
color: $ink;
}
.qr-picker {
display: flex;
min-height: 90rpx;
align-items: center;
justify-content: center;
margin-top: 18rpx;
padding: 12rpx;
border: 1rpx dashed rgba(159, 35, 35, 0.45);
color: $brand-red;
text-align: center;
gap: 16rpx;
}
.qr-picker image {
width: 80rpx;
height: 80rpx;
}
.form-error {
color: $brand-red !important;
line-height: 1.5;
text-align: center;
}
</style>
@@ -0,0 +1,397 @@
<template>
<view class="invitation-summary-card">
<view class="invitation-summary-card__heading">
<view>
<text>活动邀请</text>
<text>仅活动管理员可管理为保护隐私不展示受邀人的个人信息</text>
</view>
<view class="invitation-summary-card__actions">
<AppButton
compact
type="secondary"
:disabled="invitationState === 'loading'"
:label="invitationState === 'loading' ? '加载中' : '查看概览'"
@click="loadInvitationSummary"
/>
<AppButton
compact
type="secondary"
:disabled="inviteeManagerState === 'loading'"
:label="inviteeManagerState === 'loading' ? '加载中' : '管理受邀人'"
@click="openInviteeManager"
/>
</view>
</view>
<AppLoading v-if="invitationState === 'loading'" text="正在加载活动邀请" />
<view v-else-if="invitationState === 'ready'" class="invitation-summary-card__stats">
<text> {{ invitationRows.length }} 条邀请</text>
<text v-for="item in invitationSummary" :key="item.status">
{{ item.label }} {{ item.count }}
</text>
</view>
<view v-else-if="invitationState === 'empty'" class="invitation-summary-card__empty">
<text>暂时没有活动邀请</text>
</view>
<view v-else-if="invitationState === 'error'" class="invitation-summary-card__error">
<text>暂时无法加载活动邀请</text>
<AppButton compact type="secondary" label="重新查看" @click="loadInvitationSummary" />
</view>
<view v-if="inviteeManagerVisible" class="invitee-manager">
<text class="invitee-manager__title">选择受邀成员</text>
<text class="invitee-manager__note"
>仅展示可以邀请的成员保存后会更新尚未处理的活动邀请已接受或拒绝的邀请不受影响</text
>
<AppLoading
v-if="inviteeManagerState === 'loading'"
text="正在加载可邀请成员"
/>
<view v-else-if="inviteeManagerState === 'ready'" class="invitee-manager__options">
<view
v-for="item in inviteeOptions"
:key="item.appUserId"
class="invitee-manager__option"
:class="{ 'invitee-manager__option--selected': selectedInviteeUserIds.includes(item.appUserId) }"
role="checkbox"
:aria-checked="selectedInviteeUserIds.includes(item.appUserId)"
@click="toggleInvitee(item.appUserId)"
>
<text>{{ item.displayName }}</text>
<text v-if="item.memberRole">{{ item.memberRole }}</text>
</view>
<text v-if="unresolvedPendingInviteeCount" class="invitee-manager__error"
> {{ unresolvedPendingInviteeCount }} 位受邀人暂未显示在此列表中原有邀请不会受影响</text
>
<view class="invitee-manager__actions">
<AppButton
type="secondary"
label="取消"
:disabled="inviteeSubmitting"
@click="closeInviteeManager"
/>
<AppButton
:label="inviteeSubmitting ? '正在保存' : '保存受邀人'"
:disabled="inviteeSubmitting || unresolvedPendingInviteeCount > 0"
@click="requestInviteeSave"
/>
</view>
</view>
<view v-else-if="inviteeManagerState === 'empty'" class="invitee-manager__empty">
<text>当前没有可邀请成员</text>
<AppButton type="secondary" label="关闭" @click="closeInviteeManager" />
</view>
<view v-else-if="inviteeManagerState === 'error'" class="invitee-manager__error">
<text>暂时无法加载可邀请成员</text>
<AppButton compact type="secondary" label="重新查看" @click="openInviteeManager" />
</view>
</view>
<AppDialog
:visible="inviteeConfirmVisible"
eyebrow="邀请确认"
title="保存本次受邀人调整?"
message="保存后会更新尚未处理的活动邀请;已接受或拒绝的邀请不会受影响。"
:confirm-text="inviteeSubmitting ? '正在保存' : '确认保存'"
cancel-text="继续选择"
show-cancel
:close-on-mask="false"
@confirm="confirmInviteeSave"
@cancel="inviteeConfirmVisible = false"
/>
</view>
</template>
<script setup>
import { computed, onUnmounted, ref, watch } from "vue";
import AppButton from "@/components/AppButton.vue";
import AppDialog from "@/components/AppDialog.vue";
import AppLoading from "@/components/AppLoading.vue";
import {
CEREMONY_INVITATION_STATUS,
CEREMONY_INVITATION_STATUS_LABELS
} from "@/services/api/ceremony-contract.js";
import {
createRequestController,
isRequestCancelled
} from "@/services/api/request-controller.js";
import { ceremonyApi } from "@/services/api/ceremony-service.js";
const props = defineProps({
genealogyId: { type: String, required: true },
ceremonyId: { type: String, required: true },
});
const emit = defineEmits(["busy-change", "transient-change"]);
const invitationRows = ref([]);
const invitationState = ref("idle");
const inviteeManagerVisible = ref(false);
const inviteeManagerState = ref("idle");
const inviteeOptions = ref([]);
const selectedInviteeUserIds = ref([]);
const inviteeSubmitting = ref(false);
const inviteeConfirmVisible = ref(false);
const invitationListRequestController = createRequestController();
const inviteeOptionsRequestController = createRequestController();
const inviteeSaveRequestController = createRequestController();
let isActive = true;
const hasValidContext = computed(
() => /^[1-9]\d*$/.test(props.genealogyId) && /^[1-9]\d*$/.test(props.ceremonyId),
);
const invitationSummary = computed(() => {
const statusCounts = invitationRows.value.reduce((counts, invitation) => {
counts[invitation.inviteStatus] = (counts[invitation.inviteStatus] || 0) + 1;
return counts;
}, {});
return Object.entries(CEREMONY_INVITATION_STATUS_LABELS)
.map(([status, label]) => ({ status, label, count: statusCounts[status] || 0 }))
.filter((summary) => summary.count > 0);
});
const pendingInviteeUserIds = computed(() =>
invitationRows.value
.filter(
(invitation) =>
invitation.inviteStatus === CEREMONY_INVITATION_STATUS.PENDING,
)
.map((invitation) => invitation.userKey)
.filter(Boolean),
);
const unresolvedPendingInviteeCount = computed(
() =>
pendingInviteeUserIds.value.filter(
(userId) => !inviteeOptions.value.some((option) => option.appUserId === userId),
).length,
);
const hasTransient = computed(
() => inviteeConfirmVisible.value || inviteeManagerVisible.value,
);
watch(hasTransient, (value) => emit("transient-change", value), { immediate: true });
watch(inviteeSubmitting, (value) => emit("busy-change", value), { immediate: true });
const loadInvitationSummary = async () => {
if (!hasValidContext.value || invitationState.value === "loading") return;
invitationListRequestController.abort();
invitationState.value = "loading";
try {
const invitations = await ceremonyApi.getCeremonyInvitations(
props.genealogyId,
props.ceremonyId,
{ requestController: invitationListRequestController },
);
if (!isActive) return;
invitationRows.value = invitations;
invitationState.value = invitations.length ? "ready" : "empty";
} catch (error) {
if (!isActive || isRequestCancelled(error)) return;
invitationState.value = "error";
}
};
const closeInviteeManager = () => {
if (inviteeSubmitting.value) return;
inviteeConfirmVisible.value = false;
inviteeManagerVisible.value = false;
};
const openInviteeManager = async () => {
if (!hasValidContext.value || inviteeManagerState.value === "loading") return;
inviteeManagerVisible.value = true;
inviteeManagerState.value = "loading";
invitationListRequestController.abort();
inviteeOptionsRequestController.abort();
try {
const [invitations, options] = await Promise.all([
ceremonyApi.getCeremonyInvitations(props.genealogyId, props.ceremonyId, {
requestController: invitationListRequestController,
}),
ceremonyApi.getCeremonyInviteeOptions(props.genealogyId, props.ceremonyId, {
requestController: inviteeOptionsRequestController,
}),
]);
if (!isActive) return;
invitationRows.value = invitations;
invitationState.value = invitations.length ? "ready" : "empty";
inviteeOptions.value = options.filter((option) => option.eligible);
selectedInviteeUserIds.value = pendingInviteeUserIds.value.slice();
inviteeManagerState.value = inviteeOptions.value.length ? "ready" : "empty";
} catch (error) {
if (!isActive || isRequestCancelled(error)) return;
inviteeOptions.value = [];
selectedInviteeUserIds.value = [];
inviteeManagerState.value = "error";
}
};
const toggleInvitee = (appUserId) => {
if (inviteeSubmitting.value) return;
selectedInviteeUserIds.value = selectedInviteeUserIds.value.includes(appUserId)
? selectedInviteeUserIds.value.filter((selectedUserId) => selectedUserId !== appUserId)
: [...selectedInviteeUserIds.value, appUserId];
};
const requestInviteeSave = () => {
if (inviteeSubmitting.value || unresolvedPendingInviteeCount.value) return;
inviteeConfirmVisible.value = true;
};
const confirmInviteeSave = async () => {
if (inviteeSubmitting.value || unresolvedPendingInviteeCount.value) return;
inviteeSubmitting.value = true;
try {
const invitations = await ceremonyApi.replaceCeremonyInvitees(
props.genealogyId,
props.ceremonyId,
{ inviteeUserIds: selectedInviteeUserIds.value },
{ requestController: inviteeSaveRequestController },
);
if (!isActive) return;
invitationRows.value = invitations;
invitationState.value = invitations.length ? "ready" : "empty";
inviteeConfirmVisible.value = false;
inviteeManagerVisible.value = false;
} catch (error) {
if (!isActive || isRequestCancelled(error)) return;
inviteeManagerState.value = "error";
inviteeConfirmVisible.value = false;
} finally {
if (isActive) inviteeSubmitting.value = false;
}
};
const closeTransient = () => {
if (inviteeConfirmVisible.value) {
inviteeConfirmVisible.value = false;
return true;
}
if (inviteeManagerVisible.value) {
closeInviteeManager();
return true;
}
return false;
};
defineExpose({ closeTransient });
onUnmounted(() => {
isActive = false;
invitationListRequestController.abort();
inviteeOptionsRequestController.abort();
inviteeSaveRequestController.abort();
});
</script>
<style scoped lang="scss">
@import "../../styles/adaptive-frame-profiles.scss";
.invitation-summary-card {
@include adaptive-records-content;
display: flex;
flex-direction: column;
gap: 16rpx;
margin-top: 18rpx;
padding: 28rpx 32rpx;
}
.invitation-summary-card__heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 18rpx;
}
.invitation-summary-card__heading > view { min-width: 0; flex: 1; }
.invitation-summary-card__actions {
display: flex;
flex: 0 0 auto;
flex-wrap: wrap;
justify-content: flex-end;
gap: 10rpx;
}
.invitation-summary-card__heading text,
.invitation-summary-card__stats text,
.invitation-summary-card__empty text,
.invitation-summary-card__error > text { display: block; }
.invitation-summary-card__heading text:first-child {
color: $ink;
font-family: STKaiti, KaiTi, serif;
font-size: clamp(17px, 30rpx, 22px);
font-weight: 700;
}
.invitation-summary-card__heading text:last-child,
.invitation-summary-card__empty,
.invitation-summary-card__error {
margin-top: 7rpx;
color: $ink-muted;
font-size: clamp(13px, 21rpx, 16px);
line-height: 1.5;
}
.invitation-summary-card__stats { display: flex; flex-wrap: wrap; gap: 12rpx; }
.invitation-summary-card__stats text {
padding: 8rpx 14rpx;
border-radius: 999rpx;
background: rgba(181, 137, 63, 0.1);
color: $ink;
font-size: clamp(13px, 21rpx, 16px);
}
.invitation-summary-card__error {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16rpx;
color: #a22b20;
}
.invitee-manager {
padding-top: 18rpx;
border-top: 1rpx solid rgba(181, 137, 63, 0.32);
}
.invitee-manager__title,
.invitee-manager__note,
.invitee-manager__option text,
.invitee-manager__empty text,
.invitee-manager__error > text { display: block; }
.invitee-manager__title {
color: $ink;
font-family: STKaiti, KaiTi, serif;
font-size: clamp(16px, 28rpx, 20px);
font-weight: 700;
}
.invitee-manager__note,
.invitee-manager__empty,
.invitee-manager__error {
margin-top: 8rpx;
color: $ink-muted;
font-size: clamp(13px, 21rpx, 16px);
line-height: 1.5;
}
.invitee-manager__options {
display: flex;
flex-direction: column;
gap: 12rpx;
margin-top: 16rpx;
}
.invitee-manager__option {
display: flex;
min-height: 72rpx;
align-items: center;
justify-content: space-between;
gap: 14rpx;
padding: 14rpx 18rpx;
border: 1rpx solid rgba(181, 137, 63, 0.42);
border-radius: 10rpx;
background: rgba(255, 252, 244, 0.56);
}
.invitee-manager__option--selected {
border-color: rgba(159, 23, 15, 0.72);
background: rgba(159, 23, 15, 0.08);
}
.invitee-manager__option text:first-child {
color: $ink;
font-size: clamp(14px, 23rpx, 17px);
font-weight: 700;
}
.invitee-manager__option text:last-child {
flex: 0 0 auto;
color: $ink-muted;
font-size: clamp(12px, 20rpx, 15px);
}
.invitee-manager__error { color: #a22b20; }
.invitee-manager__actions {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16rpx;
margin-top: 8rpx;
}
.invitee-manager__empty {
display: flex;
flex-direction: column;
gap: 14rpx;
}
</style>
@@ -0,0 +1,440 @@
<template>
<AppDialog
:visible="Boolean(detailRecord)"
eyebrow="成长详情"
:title="detailRecord?.title || '成长记录'"
confirm-text="关闭"
:close-on-mask="detailState !== 'loading'"
@confirm="close"
@cancel="close"
>
<view class="detail-content">
<AppLoading v-if="detailState === 'loading'" text="正在读取完整记录" />
<text v-else-if="detailState === 'error'" class="detail-error">{{ detailError }}</text>
<template v-else-if="detailRecord?.contentProtected && !detailRecord?.contentUnlocked">
<text>这条成长记录已设置内容密码</text>
<input
v-model="contentPassword"
class="detail-password-input"
password
maxlength="128"
placeholder="请输入8至128位内容密码"
/>
<AppButton
block
:disabled="protectionSubmitting"
:label="protectionSubmitting ? '正在验证' : '解锁并查看'"
@click="unlockContent"
/>
<button class="detail-recovery-link" @click="passwordRecoveryVisible = true">忘记内容密码</button>
<text v-if="detailError" class="detail-error">{{ detailError }}</text>
</template>
<template v-else>
<text v-if="detailError" class="detail-error">{{ detailError }}</text>
<text>关联人物{{ detailRecord?.personName || "未关联人物" }}</text>
<text>记录类型{{ detailRecord?.typeLabel || "未填写" }}</text>
<text>记录日期{{ detailRecord?.recordDate || "未填写" }}</text>
<text v-if="detailRecord?.createTime">创建时间{{ formatMinuteTimestamp(detailRecord.createTime) }}</text>
<text v-if="detailRecord?.remindTime">提醒时间{{ detailRecord.remindTime }}</text>
<text class="detail-content__body">{{ detailRecord?.content || "未填写记录内容" }}</text>
<view v-if="detailRecord?.mediaFiles?.length" class="detail-media">
<template v-for="file in detailRecord.mediaFiles" :key="file.fileId">
<video
v-if="isVideoFile(file)"
:src="file.accessUrl"
controls
:aria-label="file.fileName || '播放成长记录视频'"
/>
<image
v-else
:src="file.accessUrl"
mode="aspectFill"
role="button"
aria-label="查看成长记录图片"
@click="previewMedia(file)"
/>
</template>
</view>
<view v-if="detailRecord?.canManageProtection" class="detail-protection-actions">
<AppButton
compact
type="secondary"
:label="detailRecord.contentProtected ? '修改内容密码' : '设置内容密码'"
@click="openProtection('set')"
/>
<AppButton
v-if="detailRecord.contentProtected"
compact
type="secondary"
label="关闭内容密码"
@click="openProtection('disable')"
/>
</view>
</template>
</view>
</AppDialog>
<AppDialog
:visible="protectionVisible"
eyebrow="内容密码"
:title="protectionTitle"
:message="protectionMessage"
:confirm-text="protectionConfirmText"
cancel-text="取消"
show-cancel
:close-on-mask="false"
@confirm="saveProtection"
@cancel="closeProtection"
>
<input
v-if="protectionMode === 'set'"
v-model="contentPassword"
class="detail-password-input"
password
maxlength="128"
placeholder="请输入8至128位内容密码"
/>
<text v-if="detailError" class="detail-error">{{ detailError }}</text>
</AppDialog>
<ContentPasswordRecoveryDialog
:visible="passwordRecoveryVisible"
:genealogy-id="genealogyId"
resource-type="GROWTH_RECORD"
:resource-id="String(detailRecord?.id || '')"
@close="passwordRecoveryVisible = false"
@complete="completePasswordRecovery"
@busy-change="passwordRecoveryBusy = $event"
/>
</template>
<script setup>
import { computed, onUnmounted, ref, watch } from "vue";
import AppButton from "@/components/AppButton.vue";
import AppDialog from "@/components/AppDialog.vue";
import AppLoading from "@/components/AppLoading.vue";
import { formatMinuteTimestamp } from "@/utils/display-time.js";
import ContentPasswordRecoveryDialog from "@/components/ContentPasswordRecoveryDialog.vue";
import {
createRequestController,
isRequestCancelled
} from "@/services/api/request-controller.js";
import { lifeRecordApi } from "@/services/api/life-record-service.js";
import { getRequestErrorMessage } from "@/services/api/request-error-message.js";
const props = defineProps({
genealogyId: {
type: String,
required: true,
},
});
const emit = defineEmits(["busy-change", "transient-change"]);
const detailRecord = ref(null);
const detailState = ref("idle");
const detailError = ref("");
const contentPassword = ref("");
const protectionSubmitting = ref(false);
const protectionVisible = ref(false);
const protectionMode = ref("set");
const passwordRecoveryVisible = ref(false);
const passwordRecoveryBusy = ref(false);
const growthDetailReadRequestController = createRequestController();
const growthDetailUnlockRequestController = createRequestController();
const growthProtectionMutationRequestController = createRequestController();
let componentActive = true;
const protectionTitle = computed(() => {
if (protectionMode.value === "disable") return "关闭内容密码?";
return detailRecord.value?.contentProtected ? "修改内容密码" : "设置内容密码";
});
const protectionMessage = computed(() =>
protectionMode.value === "disable"
? "关闭后,有权查看记录的成员无需密码即可阅读。"
: "设置8至128位密码,之后查看完整内容需要先验证。",
);
const protectionConfirmText = computed(() => {
if (protectionSubmitting.value) return "正在保存";
return protectionMode.value === "disable" ? "确认关闭" : "确认保存";
});
const isBusy = computed(() =>
detailState.value === "loading" || protectionSubmitting.value || passwordRecoveryBusy.value,
);
const hasTransient = computed(() =>
Boolean(detailRecord.value) || protectionVisible.value,
);
watch(isBusy, (busy) => emit("busy-change", busy), { immediate: true });
watch(hasTransient, (visible) => emit("transient-change", visible), { immediate: true });
const shouldIgnoreFailure = (cause) =>
!componentActive || isRequestCancelled(cause);
const isCurrentRecord = (recordId) =>
componentActive && String(detailRecord.value?.id || "") === String(recordId);
const clearDetailState = () => {
detailRecord.value = null;
detailState.value = "idle";
detailError.value = "";
contentPassword.value = "";
protectionVisible.value = false;
passwordRecoveryVisible.value = false;
};
const applyRecordDetail = (recordDetail) => {
detailRecord.value = {
...recordDetail,
typeLabel:
recordDetail.typeLabel || detailRecord.value?.typeLabel || "",
};
};
const open = async (record) => {
if (!record?.id || isBusy.value) return;
const recordId = String(record.id);
growthDetailReadRequestController.abort();
detailRecord.value = record;
detailState.value = "loading";
detailError.value = "";
contentPassword.value = "";
try {
const recordDetail = await lifeRecordApi.getGrowthRecordDetail(
props.genealogyId,
recordId,
"",
{ requestController: growthDetailReadRequestController },
);
if (!isCurrentRecord(recordId)) return;
applyRecordDetail(recordDetail);
detailState.value = "ready";
} catch (cause) {
if (shouldIgnoreFailure(cause) || !isCurrentRecord(recordId)) return;
detailState.value = "error";
detailError.value = getRequestErrorMessage(cause, "完整记录读取失败,请稍后重试。");
}
};
const unlockContent = async () => {
if (!detailRecord.value?.id || protectionSubmitting.value) return;
if (contentPassword.value.length < 8 || contentPassword.value.length > 128) {
detailError.value = "请输入8至128位内容密码。";
return;
}
protectionSubmitting.value = true;
detailError.value = "";
const recordId = String(detailRecord.value.id);
let passwordAccepted = false;
try {
const grant = await lifeRecordApi.unlockGrowthRecord(
props.genealogyId,
recordId,
contentPassword.value,
{ requestController: growthDetailUnlockRequestController },
);
if (!isCurrentRecord(recordId)) return;
passwordAccepted = true;
contentPassword.value = "";
const recordDetail = await lifeRecordApi.getGrowthRecordDetail(
props.genealogyId,
recordId,
grant.accessToken,
{ requestController: growthDetailReadRequestController },
);
if (!isCurrentRecord(recordId)) return;
applyRecordDetail(recordDetail);
detailState.value = "ready";
} catch (cause) {
if (!shouldIgnoreFailure(cause) && isCurrentRecord(recordId)) {
detailError.value = getRequestErrorMessage(
cause,
passwordAccepted
? "密码已验证,但完整内容暂时无法读取,请稍后重试。"
: "密码不正确,请重新输入。",
);
}
} finally {
if (isCurrentRecord(recordId)) protectionSubmitting.value = false;
}
};
const completePasswordRecovery = (newPassword) => {
contentPassword.value = newPassword;
detailError.value = "内容密码已重置,请点击“解锁并查看”确认。";
};
const openProtection = (mode) => {
if (!detailRecord.value?.canManageProtection || protectionSubmitting.value) return;
protectionMode.value = mode;
contentPassword.value = "";
detailError.value = "";
protectionVisible.value = true;
};
const closeProtection = () => {
if (protectionSubmitting.value) return;
protectionVisible.value = false;
contentPassword.value = "";
detailError.value = "";
};
const saveProtection = async () => {
if (!detailRecord.value?.canManageProtection || protectionSubmitting.value) return;
if (
protectionMode.value === "set" &&
(contentPassword.value.length < 8 || contentPassword.value.length > 128)
) {
detailError.value = "请输入8至128位内容密码。";
return;
}
protectionSubmitting.value = true;
detailError.value = "";
const recordId = String(detailRecord.value.id);
const contentWillBeProtected = protectionMode.value !== "disable";
let protectionCommitted = false;
try {
if (protectionMode.value === "disable") {
await lifeRecordApi.disableGrowthRecordPassword(
props.genealogyId,
recordId,
{ requestController: growthProtectionMutationRequestController },
);
} else {
await lifeRecordApi.setGrowthRecordPassword(
props.genealogyId,
recordId,
contentPassword.value,
{ requestController: growthProtectionMutationRequestController },
);
}
if (!isCurrentRecord(recordId)) return;
protectionCommitted = true;
protectionVisible.value = false;
contentPassword.value = "";
detailRecord.value = {
...detailRecord.value,
contentProtected: contentWillBeProtected,
contentUnlocked: !contentWillBeProtected,
};
const recordDetail = await lifeRecordApi.getGrowthRecordDetail(
props.genealogyId,
recordId,
"",
{ requestController: growthDetailReadRequestController },
);
if (!isCurrentRecord(recordId)) return;
applyRecordDetail(recordDetail);
} catch (cause) {
if (!shouldIgnoreFailure(cause) && isCurrentRecord(recordId)) {
detailError.value = getRequestErrorMessage(
cause,
protectionCommitted
? "内容密码已保存,但详情暂时没有刷新,请稍后重新打开。"
: "内容密码设置没有保存,请稍后重试。",
);
if (protectionCommitted) detailState.value = "ready";
}
} finally {
if (isCurrentRecord(recordId)) protectionSubmitting.value = false;
}
};
const close = () => {
if (isBusy.value) return false;
growthDetailReadRequestController.abort();
growthDetailUnlockRequestController.abort();
growthProtectionMutationRequestController.abort();
clearDetailState();
return true;
};
const closeTransient = () => {
if (passwordRecoveryVisible.value) {
if (passwordRecoveryBusy.value) return false;
passwordRecoveryVisible.value = false;
return true;
}
if (protectionVisible.value) {
closeProtection();
return !protectionSubmitting.value;
}
return detailRecord.value ? close() : false;
};
const previewMedia = (file) => {
const urls = detailRecord.value?.mediaFiles
?.filter((mediaFile) => !isVideoFile(mediaFile))
?.map((mediaFile) => mediaFile.accessUrl)
.filter(Boolean) || [];
if (!file?.accessUrl || !urls.length || typeof uni?.previewImage !== "function") return;
uni.previewImage({ current: file.accessUrl, urls });
};
const isVideoFile = (file) => {
const mediaType = String(file?.mediaType || "").toLowerCase();
const fileName = String(file?.fileName || "");
return mediaType === "video" || mediaType.startsWith("video/") || /\.(mp4|mov|m4v|webm|avi|mkv)$/i.test(fileName);
};
defineExpose({ closeTransient, open });
onUnmounted(() => {
componentActive = false;
growthDetailReadRequestController.abort();
growthDetailUnlockRequestController.abort();
growthProtectionMutationRequestController.abort();
});
</script>
<style scoped lang="scss">
.detail-content {
width: 100%;
margin-top: 18rpx;
text-align: left;
}
.detail-content > text {
display: block;
margin-top: 9rpx;
color: $ink-muted;
font-size: clamp(14px, 23rpx, 17px);
line-height: 1.55;
overflow-wrap: anywhere;
}
.detail-content__body {
padding-top: 10rpx;
border-top: 1rpx solid rgba(142, 95, 41, .2);
color: $ink !important;
white-space: pre-wrap;
}
.detail-media {
display: grid;
margin-top: 16rpx;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10rpx;
}
.detail-media image,
.detail-media video {
width: 100%;
height: 150rpx;
border-radius: 8rpx;
background: rgba(128, 89, 49, .12);
}
.detail-password-input {
box-sizing: border-box;
width: 100%;
min-height: 76rpx;
margin: 16rpx 0;
padding: 14rpx 18rpx;
border: 1rpx solid rgba(128, 89, 49, .32);
border-radius: 8rpx;
background: #fffdf8;
color: $ink;
font-size: clamp(14px, 22rpx, 17px);
}
.detail-recovery-link { display: block; min-height: var(--app-touch-min); margin: 4rpx auto 0; padding: 0 16rpx; border: 0; background: transparent; color: $brand-red; font-size: clamp(13px, 21rpx, 16px); }
.detail-recovery-link::after { border: 0; }
.detail-protection-actions {
display: flex;
flex-wrap: wrap;
margin-top: 18rpx;
gap: 10rpx;
}
.detail-error {
display: block;
margin-top: 10rpx;
color: $brand-red !important;
font-size: clamp(13px, 21rpx, 16px);
line-height: 1.5;
}
</style>
+374
View File
@@ -0,0 +1,374 @@
<template>
<view v-if="visible" class="member-action-panel-layer" @click="$emit('close')">
<view
class="member-action-panel"
role="dialog"
aria-modal="true"
:aria-label="member ? `管理${member.name}` : '管理人物'"
@click.stop
>
<image
class="member-action-panel__paper"
src="/static/assets/modules/tree/transparent/action-panel-paper.png"
mode="scaleToFill"
/>
<view class="member-action-panel__head">
<view
v-if="member"
class="member-action-profile"
role="button"
:aria-label="`查看${member.name}的资料`"
@click="$emit('view-profile')"
>
<view class="member-action-profile__avatar">
<AppAvatar :sex="member.sex" />
</view>
<view class="member-action-profile__copy">
<text>{{ member.name }}</text>
<text> {{ member.generation }} · {{ member.relation }}</text>
<text>点击查看人物资料</text>
</view>
</view>
<view
class="member-action-panel__close-button"
role="button"
aria-label="关闭人物操作"
@click="$emit('close')"
>
<image
class="member-action-panel__close"
src="/static/assets/modules/tree/transparent/action-close.png"
mode="aspectFit"
/>
</view>
</view>
<scroll-view class="member-action-panel__content" scroll-y>
<view class="member-action-panel__scroll-body">
<image
class="member-action-panel__relationship-divider"
src="/static/assets/modules/genealogy/transparent/section-divider.png"
mode="scaleToFill"
/>
<view class="member-action-map">
<image
class="member-action-map__graph"
src="/static/assets/modules/tree/transparent/relation-map.png"
mode="scaleToFill"
/>
<view
v-for="action in relationActions"
:key="action.key"
class="member-action-map__item"
:class="`member-action-map__item--${action.slot}`"
role="button"
:aria-label="action.label"
@click="$emit('select-action', action)"
>
<image
class="member-action-map__item-frame"
src="/static/assets/modules/tree/transparent/relation-button-frame.png"
mode="scaleToFill"
/>
<image
src="/static/assets/modules/tree/transparent/relation-marker.png"
mode="aspectFit"
/>
<text>{{ action.shortLabel }}</text>
<image
class="member-action-map__arrow"
src="/static/assets/modules/tree/transparent/action-arrow.png"
mode="aspectFit"
/>
</view>
</view>
<view class="member-action-grid">
<view
v-for="action in managementActions"
:key="action.key"
class="member-action-grid__item"
role="button"
:aria-label="action.label"
@click="$emit('select-action', action)"
>
<image
src="/static/assets/modules/tree/transparent/management-marker.png"
mode="aspectFit"
/>
<text>{{ action.label }}</text>
<image
class="member-action-grid__arrow"
src="/static/assets/modules/tree/transparent/action-arrow.png"
mode="aspectFit"
/>
</view>
</view>
<text class="member-action-panel__hint">所有操作都将作用于当前人物</text>
</view>
</scroll-view>
</view>
</view>
</template>
<script setup>
import AppAvatar from "@/components/AppAvatar.vue";
defineProps({
visible: { type: Boolean, default: false },
member: { type: Object, default: null },
relationActions: { type: Array, default: () => [] },
managementActions: { type: Array, default: () => [] },
});
defineEmits(["close", "view-profile", "select-action"]);
</script>
<style scoped lang="scss">
.member-action-panel-layer {
position: fixed;
z-index: 80;
inset: 0;
display: flex;
align-items: flex-end;
justify-content: center;
box-sizing: border-box;
padding: calc(24rpx + env(safe-area-inset-top)) 0 0;
background: rgba(35, 18, 10, 0.62);
}
.member-action-panel {
position: relative;
display: flex;
width: 100%;
max-width: 100%;
height: 900rpx;
min-height: 0;
max-height: calc(
var(--app-viewport-height, 100vh) - 80rpx - env(safe-area-inset-top) -
env(safe-area-inset-bottom)
);
flex-direction: column;
border-radius: 34rpx 34rpx 0 0;
background: #f8edda;
overflow: hidden;
}
.member-action-panel__paper {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
pointer-events: none;
}
.member-action-panel__head {
position: absolute;
top: 52rpx;
right: 36rpx;
left: 36rpx;
z-index: 2;
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
padding: 0;
}
.member-action-panel__close-button {
position: absolute;
top: 8rpx;
right: 0;
display: flex;
width: 72rpx;
height: 72rpx;
flex: 0 0 72rpx;
align-items: center;
justify-content: center;
}
.member-action-panel__close {
width: 62rpx;
height: 62rpx;
}
.member-action-panel__content {
z-index: 1;
min-height: 0;
flex: 1;
width: 100%;
box-sizing: border-box;
}
.member-action-panel__scroll-body {
padding: 160rpx 56rpx calc(24rpx + env(safe-area-inset-bottom));
}
.member-action-panel__relationship-divider {
display: block;
width: 100%;
height: 18rpx;
margin-bottom: 20rpx;
}
.member-action-profile {
display: flex;
width: auto;
flex: 0 1 auto;
align-items: center;
gap: 16rpx;
justify-content: center;
padding: 0;
box-sizing: border-box;
}
.member-action-profile__avatar {
display: block;
width: 92rpx;
height: 92rpx;
aspect-ratio: 1;
flex: 0 0 92rpx;
box-sizing: border-box;
border: 3rpx solid #d0a65d;
border-radius: 50%;
background: #fff8e8;
box-shadow: 0 3rpx 8rpx rgba(105, 65, 29, 0.18);
overflow: hidden;
}
.member-action-profile__copy {
min-width: 0;
text-align: center;
}
.member-action-profile__copy text {
display: block;
}
.member-action-profile__copy text:first-child {
color: #70261f;
font-family: "STKaiti", "KaiTi", serif;
font-size: clamp(20px, 38rpx, 26px);
font-weight: 700;
}
.member-action-profile__copy text:nth-child(2) {
margin-top: 4rpx;
color: #74533a;
font-size: clamp(14px, 22rpx, 17px);
}
.member-action-profile__copy text:last-child {
display: none;
}
.member-action-map {
position: relative;
width: 100%;
height: 350rpx;
}
.member-action-map__graph {
position: absolute;
inset: 0;
z-index: 0;
width: 100%;
height: 100%;
pointer-events: none;
}
.member-action-map__item {
z-index: 1;
position: absolute;
display: flex;
width: 190rpx;
min-width: 0;
min-height: 60rpx;
align-items: center;
justify-content: center;
gap: 4rpx;
padding: 0 10rpx;
box-sizing: border-box;
border: 1rpx solid rgba(177, 126, 64, 0.48);
border-radius: 16rpx;
background: rgba(250, 236, 211, 0.9);
box-shadow: 0 3rpx 7rpx rgba(94, 56, 24, 0.08);
}
.member-action-map__item-frame {
position: absolute;
inset: 0;
z-index: 0;
width: 100%;
height: 100%;
filter: drop-shadow(0 3rpx 3rpx rgba(99, 57, 21, 0.12));
pointer-events: none;
}
.member-action-map__item > image:not(.member-action-map__item-frame) {
z-index: 1;
width: 36rpx;
height: 36rpx;
}
.member-action-map__item .member-action-map__arrow {
z-index: 1;
width: 18rpx;
height: 18rpx;
}
.member-action-map__item text {
z-index: 1;
color: #672820;
font-size: clamp(14px, 22rpx, 17px);
font-weight: 700;
white-space: nowrap;
}
.member-action-map__item--top {
top: 0;
left: 50%;
transform: translateX(-50%);
}
.member-action-map__item--left-top {
top: 66rpx;
left: 0;
}
.member-action-map__item--right-top {
top: 66rpx;
right: 0;
}
.member-action-map__item--left-bottom {
bottom: 106rpx;
left: 0;
}
.member-action-map__item--right-bottom {
right: 0;
bottom: 106rpx;
}
.member-action-map__item--bottom {
right: 50%;
bottom: 0;
transform: translateX(50%);
}
.member-action-grid {
display: grid;
width: 100%;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 18rpx;
margin-top: 34rpx;
padding-top: 30rpx;
border-top: 2rpx solid rgba(169, 117, 55, 0.68);
}
.member-action-grid__item {
display: flex;
min-height: 96rpx;
align-items: center;
gap: 14rpx;
padding: 0 20rpx;
box-sizing: border-box;
border: 2rpx solid rgba(160, 102, 47, 0.62);
border-radius: 10rpx;
background: rgba(249, 234, 204, 0.92);
}
.member-action-grid__item image {
width: 38rpx;
height: 38rpx;
}
.member-action-grid__item .member-action-grid__arrow {
width: 22rpx;
height: 22rpx;
margin-left: auto;
}
.member-action-grid__item text {
color: #672820;
font-size: clamp(15px, 25rpx, 18px);
font-weight: 700;
}
.member-action-panel__hint {
display: none;
}
@media (max-width: 360px) {
.member-action-profile {
gap: 14rpx;
padding-right: 14rpx;
padding-left: 14rpx;
}
}
</style>
+423
View File
@@ -0,0 +1,423 @@
<template>
<view v-if="visible" class="member-date-picker" role="dialog" :aria-label="title">
<view class="member-date-picker__mask" @click="$emit('cancel')" />
<view class="member-date-picker__panel">
<view class="member-date-picker__heading">
<text class="member-date-picker__title">{{ title }}</text>
<text class="member-date-picker__hint">点击年份可直接切换左右按钮切换月份</text>
</view>
<view class="member-date-picker__toolbar">
<button
class="member-date-picker__month-button"
aria-label="上个月"
:disabled="!canSelectPreviousMonth"
@click="changeMonth(-1)"
>
<image
class="member-date-picker__month-icon member-date-picker__month-icon--previous"
src="/static/assets/foundation/transparent/chevron-right.png"
mode="aspectFit"
/>
</button>
<view class="member-date-picker__current-month">
<picker
:range="yearLabels"
:value="selectedYearIndex"
@change="selectYear"
>
<button class="member-date-picker__year-button">
<text>选择年份</text>
<text>{{ selectedYear }}</text>
</button>
</picker>
<picker
:range="monthLabels"
:value="selectedMonthIndex"
@change="selectMonth"
>
<button class="member-date-picker__month-button-direct" aria-label="选择月份">
<text>选择月份</text>
<text>{{ selectedMonth }}</text>
</button>
</picker>
</view>
<button
class="member-date-picker__month-button"
aria-label="下个月"
:disabled="!canSelectNextMonth"
@click="changeMonth(1)"
>
<image
class="member-date-picker__month-icon"
src="/static/assets/foundation/transparent/chevron-right.png"
mode="aspectFit"
/>
</button>
</view>
<view class="member-date-picker__weekdays">
<text v-for="weekday in weekdays" :key="weekday">{{ weekday }}</text>
</view>
<view class="member-date-picker__calendar">
<view
v-for="calendarDay in calendarDays"
:key="calendarDay.key"
class="member-date-picker__day-slot"
>
<button
v-if="calendarDay.day"
class="member-date-picker__day"
:class="{
'member-date-picker__day--selected': calendarDay.day === selectedDay,
}"
:disabled="calendarDay.disabled"
:aria-label="`${selectedYear}年${selectedMonth}月${calendarDay.day}日`"
@click="selectedDay = calendarDay.day"
>{{ calendarDay.day }}</button>
</view>
</view>
<view class="member-date-picker__footer">
<button class="member-date-picker__cancel" @click="$emit('cancel')">取消</button>
<button class="member-date-picker__confirm" @click="confirmSelection">确定</button>
</view>
</view>
</view>
</template>
<script setup>
import { computed, ref, watch } from "vue";
const props = defineProps({
visible: { type: Boolean, default: false },
title: { type: String, default: "选择日期" },
value: { type: String, default: "" },
});
const emit = defineEmits(["cancel", "confirm"]);
const MIN_YEAR = 1900;
const today = new Date();
const CURRENT_YEAR = today.getFullYear();
const CURRENT_MONTH = today.getMonth() + 1;
const CURRENT_DAY = today.getDate();
const years = Array.from(
{ length: CURRENT_YEAR - MIN_YEAR + 1 },
(_, index) => MIN_YEAR + index,
);
const yearLabels = years.map((year) => `${year}`);
const weekdays = Object.freeze(["日", "一", "二", "三", "四", "五", "六"]);
const selectedYear = ref(new Date().getFullYear());
const selectedMonth = ref(new Date().getMonth() + 1);
const selectedDay = ref(new Date().getDate());
const selectedYearIndex = computed(() => selectedYear.value - MIN_YEAR);
const availableMonths = computed(() =>
Array.from(
{ length: selectedYear.value === CURRENT_YEAR ? CURRENT_MONTH : 12 },
(_, index) => index + 1,
),
);
const monthLabels = computed(() =>
availableMonths.value.map((month) => `${month}`),
);
const selectedMonthIndex = computed(() =>
Math.max(0, availableMonths.value.indexOf(selectedMonth.value)),
);
const daysInSelectedMonth = computed(() =>
new Date(selectedYear.value, selectedMonth.value, 0).getDate(),
);
const selectedMonthKey = computed(
() => selectedYear.value * 12 + selectedMonth.value - 1,
);
const firstMonthKey = MIN_YEAR * 12;
const currentMonthKey = CURRENT_YEAR * 12 + CURRENT_MONTH - 1;
const canSelectPreviousMonth = computed(() => selectedMonthKey.value > firstMonthKey);
const canSelectNextMonth = computed(() => selectedMonthKey.value < currentMonthKey);
const calendarDays = computed(() => {
const leadingDays = new Date(selectedYear.value, selectedMonth.value - 1, 1).getDay();
const slots = Array.from({ length: leadingDays }, (_, index) => ({
key: `empty-${index}`,
day: 0,
}));
for (let day = 1; day <= daysInSelectedMonth.value; day += 1) {
const disabled =
selectedYear.value === CURRENT_YEAR &&
selectedMonth.value === CURRENT_MONTH &&
day > CURRENT_DAY;
slots.push({ key: `day-${day}`, day, disabled });
}
return slots;
});
const clampSelectedDay = () => {
const latestDay =
selectedYear.value === CURRENT_YEAR && selectedMonth.value === CURRENT_MONTH
? CURRENT_DAY
: daysInSelectedMonth.value;
selectedDay.value = Math.min(selectedDay.value, latestDay);
};
const parseInitialDate = () => {
const matched = /^(\d{4})-(\d{2})-(\d{2})$/.exec(props.value);
const fallback = new Date();
const isFutureDate = matched
? new Date(Number(matched[1]), Number(matched[2]) - 1, Number(matched[3])) > today
: false;
if (isFutureDate) {
selectedYear.value = CURRENT_YEAR;
selectedMonth.value = CURRENT_MONTH;
selectedDay.value = CURRENT_DAY;
return;
}
selectedYear.value = matched
? Math.max(MIN_YEAR, Math.min(CURRENT_YEAR, Number(matched[1])))
: fallback.getFullYear();
selectedMonth.value = matched
? Math.max(1, Math.min(12, Number(matched[2])))
: fallback.getMonth() + 1;
selectedDay.value = matched ? Math.max(1, Number(matched[3])) : fallback.getDate();
if (selectedMonthKey.value > currentMonthKey) {
selectedMonth.value = CURRENT_MONTH;
}
clampSelectedDay();
};
watch(
() => props.visible,
(visible) => {
if (visible) parseInitialDate();
},
);
const selectYear = (event) => {
selectedYear.value = years[Number(event.detail.value)] || selectedYear.value;
if (selectedMonthKey.value > currentMonthKey) {
selectedMonth.value = CURRENT_MONTH;
}
clampSelectedDay();
};
const selectMonth = (event) => {
const nextMonth = availableMonths.value[Number(event.detail.value)];
if (!nextMonth) return;
selectedMonth.value = nextMonth;
clampSelectedDay();
};
const changeMonth = (offset) => {
const nextDate = new Date(selectedYear.value, selectedMonth.value - 1 + offset, 1);
const nextYear = nextDate.getFullYear();
const nextMonth = nextDate.getMonth() + 1;
const nextMonthKey = nextYear * 12 + nextMonth - 1;
if (nextMonthKey < firstMonthKey || nextMonthKey > currentMonthKey) return;
selectedYear.value = nextYear;
selectedMonth.value = nextMonth;
clampSelectedDay();
};
const padDatePart = (value) => String(value).padStart(2, "0");
const confirmSelection = () => {
if (selectedMonthKey.value > currentMonthKey) return;
clampSelectedDay();
emit(
"confirm",
`${selectedYear.value}-${padDatePart(selectedMonth.value)}-${padDatePart(
selectedDay.value,
)}`,
);
};
</script>
<style scoped lang="scss">
.member-date-picker {
position: fixed;
z-index: 90;
inset: 0;
display: flex;
align-items: flex-end;
}
.member-date-picker__mask {
position: absolute;
inset: 0;
background: rgba(43, 30, 20, 0.5);
}
.member-date-picker__panel {
position: relative;
box-sizing: border-box;
width: 100%;
padding: 28rpx 30rpx calc(28rpx + env(safe-area-inset-bottom));
border-radius: 28rpx 28rpx 0 0;
background: #fdf8ed;
box-shadow: 0 -12rpx 36rpx rgba(43, 30, 20, 0.2);
}
.member-date-picker__heading {
text-align: center;
}
.member-date-picker__title,
.member-date-picker__hint {
display: block;
}
.member-date-picker__title {
color: $ink;
font-family: STKaiti, KaiTi, serif;
font-size: clamp(20px, 36rpx, 25px);
font-weight: 700;
}
.member-date-picker__hint {
margin-top: 6rpx;
color: $ink-muted;
font-size: clamp(13px, 21rpx, 16px);
}
.member-date-picker__toolbar {
display: grid;
grid-template-columns: 76rpx minmax(0, 1fr) 76rpx;
align-items: center;
gap: 14rpx;
margin-top: 20rpx;
}
.member-date-picker__month-button {
display: flex;
width: 76rpx;
min-height: 76rpx;
align-items: center;
justify-content: center;
margin: 0;
padding: 0;
border: 1rpx solid rgba(181, 137, 63, 0.34);
border-radius: 10rpx;
background: rgba(247, 237, 218, 0.7);
}
.member-date-picker__month-button[disabled],
.member-date-picker__day[disabled] {
opacity: 0.32;
}
.member-date-picker__month-button::after,
.member-date-picker__year-button::after,
.member-date-picker__month-button-direct::after,
.member-date-picker__day::after,
.member-date-picker__cancel::after,
.member-date-picker__confirm::after {
display: none;
}
.member-date-picker__month-icon {
width: 28rpx;
height: 28rpx;
}
.member-date-picker__month-icon--previous {
transform: rotate(180deg);
}
.member-date-picker__current-month {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: 14rpx;
}
.member-date-picker__year-button {
display: grid;
width: 100%;
min-height: 76rpx;
align-content: center;
margin: 0;
padding: 6rpx 18rpx;
border: 1rpx solid rgba(159, 23, 15, 0.38);
border-radius: 10rpx;
background: rgba(255, 250, 240, 0.92);
color: $brand-red;
line-height: 1.25;
}
.member-date-picker__year-button text:first-child {
font-size: clamp(12px, 19rpx, 15px);
}
.member-date-picker__year-button text:last-child {
font-size: clamp(17px, 29rpx, 21px);
font-weight: 700;
}
.member-date-picker__month-button-direct {
display: grid;
min-width: 116rpx;
min-height: 76rpx;
align-content: center;
margin: 0;
padding: 6rpx 12rpx;
border: 1rpx solid rgba(181, 137, 63, 0.34);
border-radius: 10rpx;
background: rgba(247, 237, 218, 0.7);
color: $ink;
line-height: 1.25;
white-space: nowrap;
}
.member-date-picker__month-button-direct text:first-child {
color: $ink-muted;
font-size: clamp(12px, 19rpx, 15px);
}
.member-date-picker__month-button-direct text:last-child {
font-family: STKaiti, KaiTi, serif;
font-size: clamp(17px, 29rpx, 21px);
font-weight: 700;
}
.member-date-picker__weekdays,
.member-date-picker__calendar {
display: grid;
grid-template-columns: repeat(7, minmax(0, 1fr));
}
.member-date-picker__weekdays {
margin-top: 20rpx;
color: $ink-muted;
font-size: clamp(13px, 21rpx, 16px);
text-align: center;
}
.member-date-picker__calendar {
margin-top: 8rpx;
padding: 8rpx;
border: 1rpx solid rgba(181, 137, 63, 0.3);
border-radius: 12rpx;
background: rgba(255, 252, 245, 0.76);
}
.member-date-picker__day-slot {
display: flex;
min-height: 70rpx;
align-items: center;
justify-content: center;
}
.member-date-picker__day {
display: flex;
width: 58rpx;
min-height: 58rpx;
align-items: center;
justify-content: center;
margin: 0;
padding: 0;
border: 0;
border-radius: 50%;
background: transparent;
color: $ink;
font-size: clamp(14px, 23rpx, 18px);
line-height: 1;
}
.member-date-picker__day--selected {
background: $brand-red;
color: #fff9ed;
font-weight: 700;
}
.member-date-picker__footer {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16rpx;
margin-top: 20rpx;
}
.member-date-picker__cancel,
.member-date-picker__confirm {
min-height: 82rpx;
margin: 0;
border-radius: 10rpx;
font-size: clamp(16px, 28rpx, 20px);
font-weight: 700;
}
.member-date-picker__cancel {
border: 1rpx solid rgba(159, 23, 15, 0.34);
background: transparent;
color: $brand-red;
}
.member-date-picker__confirm {
border: 0;
background: $brand-red;
color: #fff9ed;
}
</style>
File diff suppressed because it is too large Load Diff
+196
View File
@@ -0,0 +1,196 @@
import { ref } from "vue";
import {
createRequestController,
isRequestCancelled
} from "@/services/api/request-controller.js";
import { authApi } from "@/services/api/auth-service.js";
import { createAuthSmsCooldown } from "@/utils/auth/sms-cooldown.js";
import {
createTacRenderContext,
isAuthPhone,
isSmsDeliveryOutcomeUnknown,
normalizeCaptchaRequirement,
normalizeTacSuccess,
} from "@/utils/auth/verification.js";
import { runtimeConfig } from "@/utils/runtime-config.js";
export const useSmsVerification = ({
operationCode,
requestIdPrefix,
phone,
isActive,
showFeedback,
getErrorMessage = (error, fallback) => error?.message || fallback,
}) => {
if (!phone || typeof phone !== "object" || !("value" in phone)) {
throw new TypeError("phone must be a Vue ref");
}
if (
typeof isActive !== "function" ||
typeof showFeedback !== "function" ||
typeof getErrorMessage !== "function"
) {
throw new TypeError("isActive, showFeedback and getErrorMessage must be functions");
}
const tacVisible = ref(false);
const tacContext = ref(null);
const sendingCode = ref(false);
const cooldownSeconds = ref(0);
const sentPhone = ref("");
const captchaRequirementRequestController = createRequestController();
const smsDeliveryRequestController = createRequestController();
const smsCooldown = createAuthSmsCooldown({
operationCode,
onChange: (seconds) => {
cooldownSeconds.value = seconds;
},
});
let requestSequence = 0;
let disposed = false;
const canCommit = () => !disposed && isActive();
const closeTac = () => {
tacVisible.value = false;
tacContext.value = null;
};
const markSmsSent = (requestedPhone, message = "验证码已发送") => {
if (!canCommit()) return;
sentPhone.value = requestedPhone;
smsCooldown.start();
showFeedback(message);
};
const requestCode = async () => {
if (sendingCode.value || cooldownSeconds.value > 0) return false;
if (!isAuthPhone(phone.value)) return false;
const requestedPhone = phone.value;
let smsDeliveryStarted = false;
sendingCode.value = true;
try {
const requirementResponse = await authApi.getCaptchaRequirement(
{ operationCode, subject: requestedPhone },
{ requestController: captchaRequirementRequestController },
);
if (!canCommit()) return false;
if (phone.value !== requestedPhone) {
throw new Error("手机号已变化,请重新获取验证码");
}
const requirement = normalizeCaptchaRequirement(requirementResponse);
if (!requirement.required) {
smsDeliveryStarted = true;
await authApi.sendSmsCode(
{ operationCode, phone: requestedPhone },
{ requestController: smsDeliveryRequestController },
);
markSmsSent(requestedPhone);
return true;
}
requestSequence += 1;
tacContext.value = createTacRenderContext({
requestId: `${requestIdPrefix}-${requestSequence}`,
baseUrl: runtimeConfig.baseUrl,
clientId: runtimeConfig.clientId,
tenantId: runtimeConfig.tenantId,
operationCode,
subject: requestedPhone,
requirement,
});
tacVisible.value = true;
return true;
} catch (error) {
if (canCommit()) {
if (smsDeliveryStarted && isSmsDeliveryOutcomeUnknown(error)) {
markSmsSent(
requestedPhone,
"发送结果未知,如收到短信可直接填写;60 秒后可重试",
);
} else if (!isRequestCancelled(error)) {
showFeedback(getErrorMessage(error, "安全验证暂不可用"));
}
}
return false;
} finally {
if (canCommit()) sendingCode.value = false;
}
};
const completeTac = async (result) => {
const expectedContext = tacContext.value;
if (!expectedContext) return false;
try {
const ticket = normalizeTacSuccess(result, expectedContext.requestId);
if (phone.value !== expectedContext.subject) {
throw new Error("手机号已变化,请重新验证");
}
closeTac();
sendingCode.value = true;
await authApi.sendSmsCode(
{
operationCode,
phone: expectedContext.subject,
validToken: ticket.validToken,
},
{ requestController: smsDeliveryRequestController },
);
markSmsSent(expectedContext.subject);
return true;
} catch (error) {
closeTac();
if (canCommit()) {
if (isSmsDeliveryOutcomeUnknown(error)) {
markSmsSent(
expectedContext.subject,
"发送结果未知,如收到短信可直接填写;60 秒后可重试",
);
} else if (!isRequestCancelled(error)) {
showFeedback(getErrorMessage(error, "验证码发送失败"));
}
}
return false;
} finally {
if (canCommit()) sendingCode.value = false;
}
};
const handleTacFailure = ({ message } = {}) =>
showFeedback(message || "行为验证未通过,请重试");
const handleTacError = ({ message } = {}) => {
closeTac();
showFeedback(message || "安全验证暂不可用");
};
const cancel = () => {
captchaRequirementRequestController.abort();
smsDeliveryRequestController.abort();
sendingCode.value = false;
};
const dispose = () => {
disposed = true;
cancel();
closeTac();
smsCooldown.dispose();
};
return {
tacVisible,
tacContext,
sendingCode,
cooldownSeconds,
sentPhone,
requestCode,
completeTac,
closeTac,
handleTacFailure,
handleTacError,
cancel,
dispose,
syncCooldown: smsCooldown.sync,
};
};
+135
View File
@@ -0,0 +1,135 @@
import { computed, ref } from "vue";
import { isRequestCancelled } from "@/services/api/request-controller.js";
import { getRequestErrorMessage } from "@/services/api/request-error-message.js";
export const useBatchDeletion = ({
items,
visibleItems = items,
getId = (item) => item?.id,
canDelete = (item) => item?.canDelete === true,
deleteOne,
resourceName,
isActive = () => true,
onEmpty = () => {},
}) => {
const selectionMode = ref(false);
const selectedIds = ref([]);
const confirmationVisible = ref(false);
const deleting = ref(false);
const notice = ref("");
const error = ref("");
const deletableItems = computed(() => visibleItems.value.filter(canDelete));
const allSelected = computed(
() =>
deletableItems.value.length > 0 &&
deletableItems.value.every((item) => selectedIds.value.includes(getId(item))),
);
const selectedCount = computed(() => selectedIds.value.length);
const confirmationMessage = computed(
() =>
`将选中的 ${selectedCount.value}${resourceName}移入回收站;管理员可在保留期内恢复。`,
);
const clearFeedback = () => {
notice.value = "";
error.value = "";
};
const exitSelectionMode = () => {
selectionMode.value = false;
selectedIds.value = [];
confirmationVisible.value = false;
};
const enterSelectionMode = () => {
if (!deletableItems.value.length || deleting.value) return;
clearFeedback();
selectedIds.value = [];
selectionMode.value = true;
};
const isSelected = (item) => selectedIds.value.includes(getId(item));
const toggleSelection = (item) => {
if (!selectionMode.value || !canDelete(item) || deleting.value) return;
const id = getId(item);
selectedIds.value = isSelected(item)
? selectedIds.value.filter((selectedId) => selectedId !== id)
: [...selectedIds.value, id];
clearFeedback();
};
const toggleAll = () => {
if (!selectionMode.value || deleting.value) return;
selectedIds.value = allSelected.value ? [] : deletableItems.value.map(getId);
clearFeedback();
};
const requestDelete = () => {
if (!selectionMode.value || !selectedIds.value.length || deleting.value) return;
clearFeedback();
confirmationVisible.value = true;
};
const cancelDelete = () => {
if (!deleting.value) confirmationVisible.value = false;
};
const confirmDelete = async () => {
if (!selectionMode.value || !selectedIds.value.length || deleting.value) return;
const pendingIds = selectedIds.value.slice();
deleting.value = true;
clearFeedback();
let deletedCount = 0;
let failedRequest = null;
try {
for (const id of pendingIds) {
const target = items.value.find((item) => getId(item) === id);
if (!target || !canDelete(target)) continue;
try {
await deleteOne(target);
} catch (cause) {
failedRequest = cause;
break;
}
if (!isActive()) return;
deletedCount += 1;
items.value = items.value.filter((item) => getId(item) !== id);
selectedIds.value = selectedIds.value.filter((selectedId) => selectedId !== id);
}
if (!isActive()) return;
confirmationVisible.value = false;
if (failedRequest && !isRequestCancelled(failedRequest)) {
const failureCopy = getRequestErrorMessage(
failedRequest,
`剩余${resourceName}删除失败,请稍后重试。`,
);
error.value = deletedCount ? `已删除 ${deletedCount} 条;${failureCopy}` : failureCopy;
} else if (deletedCount) {
notice.value = `已删除 ${deletedCount}${resourceName}`;
}
if (!items.value.length) {
exitSelectionMode();
onEmpty();
} else if (!selectedIds.value.length) {
selectionMode.value = false;
}
} finally {
if (isActive()) deleting.value = false;
}
};
return {
selectionMode,
selectedIds,
selectedCount,
deletableItems,
allSelected,
confirmationVisible,
confirmationMessage,
deleting,
notice,
error,
enterSelectionMode,
exitSelectionMode,
isSelected,
toggleSelection,
toggleAll,
requestDelete,
cancelDelete,
confirmDelete,
};
};
-68
View File
@@ -1,68 +0,0 @@
export const currentUser = {
id: 1,
name: '汤文远',
phone: '138****1024',
role: '创建者'
}
export const genealogies = [
{
id: 1001,
surname: '汤',
name: '汤氏家谱',
hall: '敦睦堂',
location: '河南·洛阳',
memberCount: 158,
updatedAt: '2024-05-12',
activeCount: 8,
visibility: '仅成员可见',
motto: '敦亲睦族,敬祖传家。'
},
{
id: 1002,
surname: '汤',
name: '汤氏宗谱',
hall: '承志堂',
location: '山东·济宁',
memberCount: 286,
updatedAt: '2024-04-28',
activeCount: 15,
visibility: '公开可申请',
motto: '继往开来,世守家风。'
}
]
export const treeMembers = [
{ id: 1, name: '汤文远', relation: '始祖', generation: 1, years: '1940—2012', branch: '主支', x: 50, y: 7 },
{ id: 2, name: '汤正国', relation: '长子', generation: 2, years: '1965—', branch: '主支', x: 20, y: 38 },
{ id: 3, name: '汤正华', relation: '次子', generation: 2, years: '1968—', branch: '主支', x: 50, y: 38 },
{ id: 4, name: '汤正强', relation: '三子', generation: 2, years: '1972—', branch: '支系', x: 80, y: 38 },
{ id: 5, name: '汤凯', relation: '长孙', generation: 3, years: '1992—', branch: '主支', x: 16, y: 70 },
{ id: 6, name: '汤悦', relation: '长女', generation: 3, years: '1995—', branch: '主支', x: 35, y: 70 },
{ id: 7, name: '汤晨', relation: '次子', generation: 3, years: '1998—', branch: '主支', x: 54, y: 70 },
{ id: 8, name: '汤昊', relation: '三子', generation: 3, years: '2001—', branch: '支系', x: 73, y: 70 },
{ id: 9, name: '汤宁', relation: '四女', generation: 3, years: '2005—', branch: '支系', x: 90, y: 70 }
]
export const familyFeeds = [
{ id: 1, author: '汤正华', title: '清明祭祖通知', content: '本周六上午举行清明祭祖,敬请各支系族亲相互转告。', date: '今天 09:30', type: '族务' },
{ id: 2, author: '汤悦', title: '老宅修缮旧影', content: '整理出一组敦睦堂老宅照片,已收入家族相册。', date: '昨天 20:18', type: '相册' }
]
export const familyContent = {
article: [{ id: 1, title: '敦睦堂家训摘录', summary: '孝友传家,勤俭立业,敬祖睦族。', date: '2025年4月' }],
album: [{ id: 1, title: '敦睦堂老宅旧影', summary: '收录修缮前后的珍贵照片,共 6 张。', date: '2025年3月' }],
ceremony: [{ id: 1, title: '清明祭祖', summary: '本周六上午举行祭祖仪式,请族人相互转告。', date: '3 天后' }],
record: [{ id: 1, title: '老宅修缮备忘', summary: '屋脊木构加固方案已确认,等待施工。', date: '今天' }]
}
export const notifications = [
{ id: 1, title: '有新的入谱申请', content: '陈先生申请加入四川武胜汤氏族谱,等待你的审核。', time: '10:24', unread: true },
{ id: 2, title: '祭祀活动提醒', content: '清明祭祖将在 3 天后开始。', time: '昨天', unread: true },
{ id: 3, title: '相册有新照片', content: '汤悦上传了 6 张老宅修缮照片。', time: '4月9日', unread: false }
]
export const joinApplications = [
{ id: 1, name: '汤志成', phone: '139****6421', relation: '自述为汤正华堂侄', appliedAt: '今天 10:24', status: 'PENDING' },
{ id: 2, name: '汤雨薇', phone: '136****2798', relation: '自述为汤正国之女', appliedAt: '昨天 18:02', status: 'PENDING' }
]
@@ -0,0 +1,84 @@
{
"schemaVersion": 1,
"kind": "runtime-asset-inventory",
"scope": "application-direct",
"imports": [],
"assets": [
{ "id": "app-foundation-opaque-root-header-cinnabar", "output": "static/assets/foundation/opaque/root-header-cinnabar.jpg", "width": 720, "height": 184, "alpha": false, "bytes": 9356, "sha256": "9561b1e4d11d3cdd005b902c6d989aadc48bd614bec5ddbd65a32699983b4b37", "provenance": "committed-binary", "rebuildable": false },
{ "id": "app-foundation-transparent-auth-title-cloud", "output": "static/assets/foundation/transparent/auth-title-cloud.png", "width": 200, "height": 120, "alpha": true, "bytes": 15833, "sha256": "c6dc6e367198e815b28b8f88c4012b850f2a21195d8ca4ecaf27c0204eba64f2", "provenance": "committed-binary", "rebuildable": false },
{ "id": "app-foundation-transparent-meta-admin", "output": "static/assets/foundation/transparent/meta-admin.png", "width": 96, "height": 96, "alpha": true, "bytes": 4705, "sha256": "d2363e3a4f990dcf5925f0b67ac51b8d3d0fe79d4e7d753ddd8df5b412a15517", "provenance": "committed-binary", "rebuildable": false },
{ "id": "app-foundation-transparent-meta-location", "output": "static/assets/foundation/transparent/meta-location.png", "width": 96, "height": 96, "alpha": true, "bytes": 3634, "sha256": "ed1285d91df018a6e7628cca944428e1582fb10ddeaf75ba7743481340adc12c", "provenance": "committed-binary", "rebuildable": false },
{ "id": "app-foundation-transparent-meta-member", "output": "static/assets/foundation/transparent/meta-member.png", "width": 96, "height": 96, "alpha": true, "bytes": 8931, "sha256": "c89824e8e19210dd746f56019a48c9a833abe4a210b222758a56c3d25dec9d0c", "provenance": "committed-binary", "rebuildable": false },
{ "id": "app-foundation-transparent-notice", "output": "static/assets/foundation/transparent/notice.png", "width": 96, "height": 96, "alpha": true, "bytes": 8775, "sha256": "a75ce2368afa661edb4b394d0a58bc514248fa994f9e732d1d6833caa79353e1", "provenance": "committed-binary", "rebuildable": false },
{ "id": "app-foundation-transparent-root-header-hall", "output": "static/assets/foundation/transparent/root-header-hall.png", "width": 750, "height": 300, "alpha": true, "bytes": 159144, "sha256": "a4b55282fb42f0200636e7d5a66cd1f237bea01521c312fa99a3b7278cf8ff49", "provenance": "committed-binary", "rebuildable": false },
{ "id": "app-foundation-transparent-tab-family-active", "output": "static/assets/foundation/transparent/tab-family-active.png", "width": 96, "height": 96, "alpha": true, "bytes": 2620, "sha256": "007f03d04927fbf6638fff027335f45db1331adb36979bcbca0d577bb28046f5", "provenance": "committed-binary", "rebuildable": false },
{ "id": "app-foundation-transparent-tab-family", "output": "static/assets/foundation/transparent/tab-family.png", "width": 96, "height": 96, "alpha": true, "bytes": 2621, "sha256": "c7e0083e4ca045b084dab6e77cbece8a26f490dec628e386689d68a57d03387a", "provenance": "committed-binary", "rebuildable": false },
{ "id": "app-foundation-transparent-tab-genealogy-active", "output": "static/assets/foundation/transparent/tab-genealogy-active.png", "width": 96, "height": 96, "alpha": true, "bytes": 3142, "sha256": "99ad57c8868eb618f62601882f7748edd5b87c81cf1d0fe5bf2324113930a88c", "provenance": "committed-binary", "rebuildable": false },
{ "id": "app-foundation-transparent-tab-genealogy", "output": "static/assets/foundation/transparent/tab-genealogy.png", "width": 96, "height": 96, "alpha": true, "bytes": 3141, "sha256": "88d5653db7521b846ef81736355e9ccbbb59daf6dfe92c9c2748bef6456af646", "provenance": "committed-binary", "rebuildable": false },
{ "id": "app-foundation-transparent-tab-profile-active", "output": "static/assets/foundation/transparent/tab-profile-active.png", "width": 96, "height": 96, "alpha": true, "bytes": 1973, "sha256": "5ff15db3b4cc64934e4ae9602e604345501c1f86160da10d30d9c0a80ef95d36", "provenance": "committed-binary", "rebuildable": false },
{ "id": "app-foundation-transparent-tab-profile", "output": "static/assets/foundation/transparent/tab-profile.png", "width": 96, "height": 96, "alpha": true, "bytes": 1973, "sha256": "45fc22b367807bdc465e6e17230a10276a4d7e453e27b7cbc064af7fdc255470", "provenance": "committed-binary", "rebuildable": false },
{ "id": "app-modules-family-transparent-family-letter-card", "output": "static/assets/modules/family/transparent/family-letter-card.png", "width": 2003, "height": 581, "alpha": true, "bytes": 1513491, "sha256": "a3686f99e32cdc255c0fc130294aab1974392d66bc8a7991daeb8f67849015ee", "provenance": "committed-binary", "rebuildable": false },
{ "id": "app-modules-family-transparent-module-content-frame", "output": "static/assets/modules/family/transparent/module-content-frame.png", "width": 2003, "height": 581, "alpha": true, "bytes": 1513491, "sha256": "a3686f99e32cdc255c0fc130294aab1974392d66bc8a7991daeb8f67849015ee", "provenance": "committed-binary", "rebuildable": false },
{ "id": "app-modules-family-transparent-module-field-frame", "output": "static/assets/modules/family/transparent/module-field-frame.png", "width": 2132, "height": 443, "alpha": true, "bytes": 1065770, "sha256": "bdc65f33e1dbe05ae75562519b0080e2e5cdbe2dba720a6ac49b56b99be75ceb", "provenance": "committed-binary", "rebuildable": false },
{ "id": "app-modules-genealogy-opaque-home-ad-family-tree", "output": "static/assets/modules/genealogy/opaque/home-ad-family-tree.png", "width": 1862, "height": 845, "alpha": false, "bytes": 2701295, "sha256": "d333924d07569491809211508302c064bba6eee52b4af489a526bf53ba066be4", "provenance": "committed-binary", "rebuildable": false },
{ "id": "app-modules-genealogy-opaque-home-ad-heritage-hall", "output": "static/assets/modules/genealogy/opaque/home-ad-heritage-hall.png", "width": 1862, "height": 845, "alpha": false, "bytes": 2554759, "sha256": "c64fbb4fc965a084a7b1184c924673f6df25a3504858df3a790973664d886b6e", "provenance": "committed-binary", "rebuildable": false },
{ "id": "app-modules-genealogy-opaque-overview-surface", "output": "static/assets/modules/genealogy/opaque/overview-surface.png", "width": 1122, "height": 1506, "alpha": false, "bytes": 2537499, "sha256": "2d908f53c7edb637c7ced7500822bd33d354410ad8e671871d1da3a4cbd0f4db", "provenance": "committed-binary", "rebuildable": false },
{ "id": "app-modules-genealogy-opaque-search-button", "output": "static/assets/modules/genealogy/opaque/search-button.png", "width": 300, "height": 132, "alpha": false, "bytes": 74664, "sha256": "bf0abc0a036c07322dd834fe5a9797413afd453b69d2ff8a895f3999fb8846cd", "provenance": "committed-binary", "rebuildable": false },
{ "id": "app-modules-genealogy-opaque-search-input-wide", "output": "static/assets/modules/genealogy/opaque/search-input-wide.png", "width": 1120, "height": 248, "alpha": false, "bytes": 319364, "sha256": "2a575eeb5582c8458cbdd04cd328afbbecbdaabf4db46e8f2133bfe2614faa80", "provenance": "committed-binary", "rebuildable": false },
{ "id": "app-modules-genealogy-transparent-add", "output": "static/assets/modules/genealogy/transparent/add.png", "width": 96, "height": 96, "alpha": true, "bytes": 5226, "sha256": "671846ee23f8df701e2e1dfc34e3520dfe9f719f106efce56e30d78b84a47a0b", "provenance": "committed-binary", "rebuildable": false },
{ "id": "app-modules-genealogy-transparent-create-cloud", "output": "static/assets/modules/genealogy/transparent/create-cloud.png", "width": 192, "height": 96, "alpha": true, "bytes": 10538, "sha256": "be01c1322181d91264641879162f6371d21ffda1feb971f5f31a791f32d02206", "provenance": "committed-binary", "rebuildable": false },
{ "id": "app-modules-genealogy-transparent-current-seal-frame", "output": "static/assets/modules/genealogy/transparent/current-seal-frame.png", "width": 144, "height": 208, "alpha": true, "bytes": 1214, "sha256": "6d9aa61d766eec077cd91fbd5b71079990e67bcdc213d631a1185714dfb09328", "provenance": "committed-binary", "rebuildable": false },
{ "id": "app-modules-genealogy-transparent-current-slip-frame", "output": "static/assets/modules/genealogy/transparent/current-slip-frame.png", "width": 720, "height": 272, "alpha": true, "bytes": 18568, "sha256": "47e519768830062a2363bd4bd10fb9ffc82c6a94638d3400a1e4c943a0ca6e66", "provenance": "committed-binary", "rebuildable": false },
{ "id": "app-modules-genealogy-transparent-form-field-frame", "output": "static/assets/modules/genealogy/transparent/form-field-frame.png", "width": 2132, "height": 443, "alpha": true, "bytes": 1065770, "sha256": "bdc65f33e1dbe05ae75562519b0080e2e5cdbe2dba720a6ac49b56b99be75ceb", "provenance": "committed-binary", "rebuildable": false },
{ "id": "app-modules-genealogy-transparent-add-sheet-background", "output": "static/assets/modules/genealogy/transparent/add-sheet-background.png", "width": 1536, "height": 1024, "alpha": true, "bytes": 1423794, "sha256": "181b9d9c95f0e8cce284dc6640adb4fa6c4fa9691306052c9e33558abf930e85", "provenance": "committed-binary", "rebuildable": false },
{ "id": "app-modules-genealogy-transparent-dialog-close", "output": "static/assets/modules/genealogy/transparent/dialog-close.png", "width": 1254, "height": 1254, "alpha": true, "bytes": 105163, "sha256": "909fe4badccbfad1ddcfe4a21292ba73fecde32efb914f1c7fcb717f32f9db3f", "provenance": "committed-binary", "rebuildable": false },
{ "id": "app-modules-genealogy-transparent-list-slip-frame", "output": "static/assets/modules/genealogy/transparent/list-slip-frame.png", "width": 720, "height": 144, "alpha": true, "bytes": 2992, "sha256": "1e39ed72c000e569340049833b1afca9e6aaeb077618a91e0c0f4f2758113005", "provenance": "committed-binary", "rebuildable": false },
{ "id": "app-modules-genealogy-transparent-row-seal-frame", "output": "static/assets/modules/genealogy/transparent/row-seal-frame.png", "width": 112, "height": 160, "alpha": true, "bytes": 875, "sha256": "f2f2b0260fe54d738a70168f28d3960622d4b3e57c4ef95a032716008de6626b", "provenance": "committed-binary", "rebuildable": false },
{ "id": "app-modules-genealogy-transparent-section-divider", "output": "static/assets/modules/genealogy/transparent/section-divider.png", "width": 720, "height": 30, "alpha": true, "bytes": 6645, "sha256": "925241a6cd2bd9897edad60e53eb255033603bef1028afbc189e87a29fddc6cd", "provenance": "committed-binary", "rebuildable": false },
{ "id": "app-modules-genealogy-transparent-shortcut-application", "output": "static/assets/modules/genealogy/transparent/shortcut-application.png", "width": 96, "height": 96, "alpha": true, "bytes": 8925, "sha256": "3842aec0357ee1013ddc230fe1030894ad5eb74101aa696bd8f986ad0c1bdd93", "provenance": "committed-binary", "rebuildable": false },
{ "id": "app-modules-genealogy-transparent-shortcut-generation-poem", "output": "static/assets/modules/genealogy/transparent/shortcut-generation-poem.png", "width": 96, "height": 96, "alpha": true, "bytes": 8169, "sha256": "688e593fa88925ea00acf37570852a2dca9e1a505ba3b8107e0ddd3d146a6f4e", "provenance": "committed-binary", "rebuildable": false },
{ "id": "app-modules-genealogy-transparent-shortcut-members", "output": "static/assets/modules/genealogy/transparent/shortcut-members.png", "width": 96, "height": 96, "alpha": true, "bytes": 8931, "sha256": "c89824e8e19210dd746f56019a48c9a833abe4a210b222758a56c3d25dec9d0c", "provenance": "committed-binary", "rebuildable": false },
{ "id": "app-modules-genealogy-transparent-shortcut-tree", "output": "static/assets/modules/genealogy/transparent/shortcut-tree.png", "width": 96, "height": 96, "alpha": true, "bytes": 6636, "sha256": "cafe12a3fa800ca79d6c559baa533d7e26124ec8ac0072482ddb89ac2167422d", "provenance": "committed-binary", "rebuildable": false },
{ "id": "app-modules-notification-transparent-notice-card", "output": "static/assets/modules/notification/transparent/notice-card.png", "width": 2139, "height": 675, "alpha": true, "bytes": 254885, "sha256": "01cf572ed3f20d99e10a21fee6834dced385e71d6b14693b04c0cb49560a6fd3", "provenance": "committed-binary", "rebuildable": false },
{ "id": "app-modules-profile-transparent-summary-card", "output": "static/assets/modules/profile/transparent/summary-card.png", "width": 2139, "height": 675, "alpha": true, "bytes": 254885, "sha256": "01cf572ed3f20d99e10a21fee6834dced385e71d6b14693b04c0cb49560a6fd3", "provenance": "committed-binary", "rebuildable": false },
{ "id": "app-modules-profile-transparent-module-content-frame", "output": "static/assets/modules/profile/transparent/module-content-frame.png", "width": 2139, "height": 675, "alpha": true, "bytes": 254885, "sha256": "01cf572ed3f20d99e10a21fee6834dced385e71d6b14693b04c0cb49560a6fd3", "provenance": "committed-binary", "rebuildable": false },
{ "id": "app-modules-profile-transparent-module-field-frame", "output": "static/assets/modules/profile/transparent/module-field-frame.png", "width": 2132, "height": 443, "alpha": true, "bytes": 1065770, "sha256": "bdc65f33e1dbe05ae75562519b0080e2e5cdbe2dba720a6ac49b56b99be75ceb", "provenance": "committed-binary", "rebuildable": false },
{ "id": "app-modules-records-transparent-module-content-frame", "output": "static/assets/modules/records/transparent/module-content-frame.png", "width": 2139, "height": 675, "alpha": true, "bytes": 254885, "sha256": "01cf572ed3f20d99e10a21fee6834dced385e71d6b14693b04c0cb49560a6fd3", "provenance": "committed-binary", "rebuildable": false },
{ "id": "app-modules-records-transparent-module-field-frame", "output": "static/assets/modules/records/transparent/module-field-frame.png", "width": 2132, "height": 443, "alpha": true, "bytes": 1065770, "sha256": "bdc65f33e1dbe05ae75562519b0080e2e5cdbe2dba720a6ac49b56b99be75ceb", "provenance": "committed-binary", "rebuildable": false },
{ "id": "app-modules-records-transparent-person-name-card", "output": "static/assets/modules/records/transparent/person-name-card.png", "width": 2139, "height": 675, "alpha": true, "bytes": 254885, "sha256": "01cf572ed3f20d99e10a21fee6834dced385e71d6b14693b04c0cb49560a6fd3", "provenance": "committed-binary", "rebuildable": false },
{ "id": "app-modules-tree-transparent-member-node-selected", "output": "static/assets/modules/tree/transparent/member-node-selected.png", "width": 720, "height": 272, "alpha": true, "bytes": 18568, "sha256": "47e519768830062a2363bd4bd10fb9ffc82c6a94638d3400a1e4c943a0ca6e66", "provenance": "committed-binary", "rebuildable": false },
{ "id": "app-modules-tree-transparent-member-node-standard", "output": "static/assets/modules/tree/transparent/member-node-standard.png", "width": 720, "height": 272, "alpha": true, "bytes": 18568, "sha256": "47e519768830062a2363bd4bd10fb9ffc82c6a94638d3400a1e4c943a0ca6e66", "provenance": "committed-binary", "rebuildable": false },
{ "id": "app-modules-tree-transparent-member-node-portrait", "output": "static/assets/modules/tree/transparent/member-node-portrait.png", "width": 81, "height": 105, "alpha": true, "bytes": 1141, "sha256": "e0e1609ae3f70ef9ab0fec82f2ef9bdb8af1ad7966a2cfefa3aae41d9684212f", "provenance": "committed-binary", "rebuildable": false },
{ "id": "app-modules-tree-transparent-state-panel", "output": "static/assets/modules/tree/transparent/state-panel.png", "width": 2139, "height": 675, "alpha": true, "bytes": 254885, "sha256": "01cf572ed3f20d99e10a21fee6834dced385e71d6b14693b04c0cb49560a6fd3", "provenance": "committed-binary", "rebuildable": false },
{ "id": "app-modules-tree-transparent-search-input-frame", "output": "static/assets/modules/tree/transparent/search-input-frame.png", "width": 2132, "height": 443, "alpha": true, "bytes": 1065770, "sha256": "bdc65f33e1dbe05ae75562519b0080e2e5cdbe2dba720a6ac49b56b99be75ceb", "provenance": "committed-binary", "rebuildable": false },
{ "id": "app-foundation-transparent-book-png", "output": "static/assets/foundation/transparent/book.png", "width": 154, "height": 210, "alpha": false, "bytes": 41283, "sha256": "d565a2ad4f90c07046e9c60ebd6658426cacdd1f2af79e0a28ce9fe59d670630", "provenance": "committed-binary", "rebuildable": false },
{ "id": "app-foundation-opaque-book-blank", "output": "static/assets/foundation/opaque/book-blank.png", "width": 154, "height": 210, "alpha": false, "bytes": 46607, "sha256": "500441f7631b45e1f4367a0b4f7f27031d1585097a458af599e8c7b922314454", "provenance": "committed-binary", "rebuildable": false },
{ "id": "app-foundation-transparent-default-avatar-female", "output": "static/assets/foundation/transparent/default-avatar-female.png", "width": 128, "height": 128, "alpha": true, "bytes": 38629, "sha256": "a85cf941e531e84217f37b42fe8e8aa9ab6a32457480d45aa3d6fa33da360d2c", "provenance": "committed-binary", "rebuildable": false },
{ "id": "app-foundation-transparent-default-avatar-male", "output": "static/assets/foundation/transparent/default-avatar-male.png", "width": 128, "height": 128, "alpha": true, "bytes": 43055, "sha256": "b00cb59f440aa87215432afc64f1463a61f0d9806ba1655ed6f8888a9d04c100", "provenance": "committed-binary", "rebuildable": false },
{ "id": "app-modules-genealogy-transparent-settings-gear", "output": "static/assets/modules/genealogy/transparent/settings-gear.png", "width": 1254, "height": 1254, "alpha": true, "bytes": 1305800, "sha256": "0c3f064878a865af57ea3e7de450703a07bbb59b81ef0c44c4a041fa35a5dd1c", "provenance": "committed-binary", "rebuildable": false },
{ "id": "app-modules-profile-opaque-archive-hero", "output": "static/assets/modules/profile/opaque/archive-hero.png", "width": 2172, "height": 724, "alpha": false, "bytes": 1965431, "sha256": "eda978404614f2ae14e200d8cf7bb05851cc7bccf15b515655b9387dc39cb829", "provenance": "committed-binary", "rebuildable": false },
{ "id": "app-modules-profile-opaque-paper-mountains", "output": "static/assets/modules/profile/opaque/paper-mountains.png", "width": 941, "height": 1672, "alpha": false, "bytes": 1862070, "sha256": "60b54ef2550a2709e9ccbe7eab1cb4fa22c08ebace4be0930f746df1a1d607b2", "provenance": "committed-binary", "rebuildable": false },
{ "id": "app-modules-profile-opaque-featured-courtyard", "output": "static/assets/modules/profile/opaque/featured-courtyard.png", "width": 1536, "height": 1024, "alpha": false, "bytes": 2577643, "sha256": "3431f3e84f1521bb73a21407d48e127d9f7d20d99805b11c38b8383f660808c7", "provenance": "committed-binary", "rebuildable": false },
{ "id": "app-modules-profile-opaque-vip-hero-landscape", "output": "static/assets/modules/profile/opaque/vip-hero-landscape.png", "width": 2172, "height": 724, "alpha": false, "bytes": 2194380, "sha256": "a286f7b685a5b9f91cdeaf99f3454e5b10c0712d7a062fca5f39f0c4a9028491", "provenance": "committed-binary", "rebuildable": false },
{ "id": "app-modules-profile-transparent-icon-calendar", "output": "static/assets/modules/profile/transparent/icon-calendar.png", "width": 256, "height": 256, "alpha": true, "bytes": 78155, "sha256": "bec3a1bce56c0bb31cac4a474f2bde9c8d904fbbc22c017e09d733364b014ed8", "provenance": "committed-binary", "rebuildable": false },
{ "id": "app-modules-profile-transparent-icon-envelope", "output": "static/assets/modules/profile/transparent/icon-envelope.png", "width": 256, "height": 256, "alpha": true, "bytes": 73975, "sha256": "2462b34ef6b48d24be4cd2ce92c7931a057a254a666d7351f77a79263d61c486", "provenance": "committed-binary", "rebuildable": false },
{ "id": "app-modules-profile-transparent-icon-feedback", "output": "static/assets/modules/profile/transparent/icon-feedback.png", "width": 256, "height": 256, "alpha": true, "bytes": 80060, "sha256": "3d194d983a2b6bea1a398d0055d68b643c2d4d91ecb3857ea967944b0900b352", "provenance": "committed-binary", "rebuildable": false },
{ "id": "app-modules-profile-transparent-icon-help", "output": "static/assets/modules/profile/transparent/icon-help.png", "width": 256, "height": 256, "alpha": true, "bytes": 79859, "sha256": "a63f9d9756b152d3eed5e2bf5ecd1797204260511bb575b5d20082db2d735cd8", "provenance": "committed-binary", "rebuildable": false },
{ "id": "app-modules-profile-transparent-icon-message", "output": "static/assets/modules/profile/transparent/icon-message.png", "width": 256, "height": 256, "alpha": true, "bytes": 75063, "sha256": "71a290aa4f76b4d786ab97e9808994636eb5f6ef252b3feaf6ac9c7e3d4c90f9", "provenance": "committed-binary", "rebuildable": false },
{ "id": "app-modules-profile-transparent-icon-person", "output": "static/assets/modules/profile/transparent/icon-person.png", "width": 256, "height": 256, "alpha": true, "bytes": 76220, "sha256": "e2cb0d86614a1ae4c8da7b56426c1399d47c6b6c198d22fb11e9e21e0f4ab32d", "provenance": "committed-binary", "rebuildable": false },
{ "id": "app-modules-profile-transparent-icon-promotion", "output": "static/assets/modules/profile/transparent/icon-promotion.png", "width": 256, "height": 256, "alpha": true, "bytes": 77714, "sha256": "81fa5248ce378a0f4b33dc5396a8b1b753c74f1c55feb19c874b43d632c2ae41", "provenance": "committed-binary", "rebuildable": false },
{ "id": "app-modules-profile-transparent-icon-security", "output": "static/assets/modules/profile/transparent/icon-security.png", "width": 256, "height": 256, "alpha": true, "bytes": 83638, "sha256": "fe7bc7cbda827cba6d2e53add1e601f3296dcf0c7a42973878ea4c23a7a8ad74", "provenance": "committed-binary", "rebuildable": false },
{ "id": "app-modules-profile-transparent-icon-settings", "output": "static/assets/modules/profile/transparent/icon-settings.png", "width": 256, "height": 256, "alpha": true, "bytes": 84885, "sha256": "f10c83e75135b2cc80c97963be5b9a8552fbe767c0a518f0106ede41cf441ccb", "provenance": "committed-binary", "rebuildable": false },
{ "id": "app-modules-profile-transparent-icon-vip", "output": "static/assets/modules/profile/transparent/icon-vip.png", "width": 256, "height": 256, "alpha": true, "bytes": 80349, "sha256": "66b08710a87d174cb38d24d6db75883a4819a074b848bbb5e7232bf9389e68ab", "provenance": "committed-binary", "rebuildable": false },
{ "id": "app-modules-profile-transparent-edit-button", "output": "static/assets/modules/profile/transparent/edit-button.png", "width": 1713, "height": 395, "alpha": true, "bytes": 1215863, "sha256": "58d9d8b2d57e2f7922f608122d1cdb5075e6a9e3b171ba2ce4100817b87d37e8", "provenance": "committed-binary", "rebuildable": false },
{ "id": "app-modules-profile-transparent-tree-medallion", "output": "static/assets/modules/profile/transparent/tree-medallion.png", "width": 1254, "height": 1254, "alpha": true, "bytes": 2998737, "sha256": "831ffb68e4d2f0a0c44a9ddabd299e0209fc89fd007c5a1c1d4b709c6859b83d", "provenance": "committed-binary", "rebuildable": false },
{ "id": "app-modules-tree-transparent-action-arrow", "output": "static/assets/modules/tree/transparent/action-arrow.png", "width": 96, "height": 96, "alpha": true, "bytes": 2353, "sha256": "d7d80ce66b959ff29be31fb21e9b53f32289339bcdc98c13b883693754eedaf9", "provenance": "committed-binary", "rebuildable": false },
{ "id": "app-modules-tree-transparent-action-close", "output": "static/assets/modules/tree/transparent/action-close.png", "width": 128, "height": 128, "alpha": true, "bytes": 5660, "sha256": "c72e937ef06bf89a42b7958f48f3b38eecea66c16013c703ca4bc76c1b7a02e7", "provenance": "committed-binary", "rebuildable": false },
{ "id": "app-modules-tree-transparent-action-panel-paper", "output": "static/assets/modules/tree/transparent/action-panel-paper.png", "width": 1034, "height": 1520, "alpha": true, "bytes": 2456802, "sha256": "85d219e828ecbe8042f47fc39587f05134d1bed3300a0ccc201426075849c4ae", "provenance": "committed-binary", "rebuildable": false },
{ "id": "app-modules-tree-transparent-management-marker", "output": "static/assets/modules/tree/transparent/management-marker.png", "width": 128, "height": 128, "alpha": true, "bytes": 14014, "sha256": "389c4bb4964c0a7f8ef37cd650f6eda8ed50952eb0cce5f86d9643a30bf93e31", "provenance": "committed-binary", "rebuildable": false },
{ "id": "app-modules-tree-transparent-member-medallion", "output": "static/assets/modules/tree/transparent/member-medallion.png", "width": 192, "height": 192, "alpha": true, "bytes": 37801, "sha256": "9f3765f9ce055b95a84fd0c6f7e4844793caf1ac27c7ac2ac628beccbd5883ec", "provenance": "committed-binary", "rebuildable": false },
{ "id": "app-modules-tree-transparent-relation-button-frame", "output": "static/assets/modules/tree/transparent/relation-button-frame.png", "width": 1591, "height": 406, "alpha": true, "bytes": 1148107, "sha256": "bffdc12e8d0179fc3c3f38eeb7d5529c1274266205d3f78258887e961f1f8fbb", "provenance": "committed-binary", "rebuildable": false },
{ "id": "app-modules-tree-transparent-relation-map", "output": "static/assets/modules/tree/transparent/relation-map.png", "width": 1536, "height": 1024, "alpha": true, "bytes": 156562, "sha256": "550f20ea224ca90be6e5a4ee5ff4bf37716bf4d7f0c77f699e788e3a2c07f8f3", "provenance": "committed-binary", "rebuildable": false },
{ "id": "app-modules-tree-transparent-relation-marker", "output": "static/assets/modules/tree/transparent/relation-marker.png", "width": 128, "height": 128, "alpha": true, "bytes": 9268, "sha256": "a5afafa89435f5df5205a6e0537d9982e713fe97b7deb6e80754c71e2a86db31", "provenance": "committed-binary", "rebuildable": false }
]
}
@@ -0,0 +1,23 @@
{
"schemaVersion": 1,
"kind": "runtime-asset-inventory",
"scope": "auth",
"imports": [],
"assets": [
{ "id": "auth-page-paper", "output": "static/assets/foundation/opaque/auth-page-paper.jpg", "width": 750, "height": 1334, "alpha": false, "bytes": 60355, "sha256": "3eea1eefa815c306f0f4a95ce79878ca53f8f64a7c9b44144c99ca683a38e7b8", "provenance": "committed-binary", "rebuildable": false },
{ "id": "auth-divider-knot", "output": "static/assets/foundation/transparent/auth-divider-knot.png", "width": 160, "height": 96, "alpha": true, "bytes": 6751, "sha256": "d45dc052c8eb2ba214c7d4d5846f5b53f01686e00e671bfe0582ed7d9d4a2419", "provenance": "committed-binary", "rebuildable": false },
{ "id": "auth-wechat", "output": "static/assets/foundation/transparent/auth-wechat.png", "width": 96, "height": 96, "alpha": true, "bytes": 2215, "sha256": "5c69addac53afcd34064e48fffc19ef56a5d105c92f6130cd6045dd1591221d1", "provenance": "committed-binary", "rebuildable": false },
{ "id": "brand-seal", "output": "static/assets/foundation/transparent/brand-seal.png", "width": 240, "height": 288, "alpha": true, "bytes": 101898, "sha256": "865e15edd6a1b50aa298ccdff5babf0f3140906026ce76d3a64631027820a7ae", "provenance": "committed-binary", "rebuildable": false },
{ "id": "chevron-right", "output": "static/assets/foundation/transparent/chevron-right.png", "width": 96, "height": 96, "alpha": true, "bytes": 2106, "sha256": "82f4996e118832108dba4aa33d320131fee300970679f6ce8cf4b09bda62b700", "provenance": "committed-binary", "rebuildable": false },
{ "id": "auth-backdrop", "output": "static/assets/modules/auth/opaque/sign-in-backdrop.png", "width": 824, "height": 1830, "alpha": false, "bytes": 669577, "sha256": "478d0f56d669ac1597cfc2a1082740e73550f07d655286762f3306410c19eb11", "provenance": "committed-binary", "rebuildable": false },
{ "id": "auth-header", "output": "static/assets/modules/auth/opaque/auth-header.png", "width": 824, "height": 340, "alpha": false, "bytes": 504001, "sha256": "6ce03f4962e8bdd0272ed00a82efded21becdf350a1fb0880c595d7d9c15200d", "provenance": "committed-binary", "rebuildable": false },
{ "id": "auth-password-hidden", "output": "static/assets/modules/auth/transparent/password-hidden.png", "width": 96, "height": 96, "alpha": true, "bytes": 3906, "sha256": "b9304de963cacaa5ecd133a9835174f0c77761d9f264afec900460c9aa983b4f", "provenance": "committed-binary", "rebuildable": false },
{ "id": "auth-password-visible", "output": "static/assets/modules/auth/transparent/password-visible.png", "width": 96, "height": 96, "alpha": true, "bytes": 2868, "sha256": "aa6a78b3f0ecb2d47963b9aa04c98db1b2981497f52f58ff8c602cff80fab21a", "provenance": "committed-binary", "rebuildable": false },
{ "id": "auth-icon-password", "output": "static/assets/modules/auth/transparent/icon-password.png", "width": 96, "height": 96, "alpha": true, "bytes": 2649, "sha256": "c1b13de86ffee9a9f2e7534d58ddc3bf155d5341eeddbd5998ef3747d57fdea8", "provenance": "committed-binary", "rebuildable": false },
{ "id": "auth-icon-phone", "output": "static/assets/modules/auth/transparent/icon-phone.png", "width": 96, "height": 96, "alpha": true, "bytes": 925, "sha256": "5110cfb1cc84b00145506b3cda3fc35860400d16404e606428902dfe0fa7ec2e", "provenance": "committed-binary", "rebuildable": false },
{ "id": "auth-icon-verification-code", "output": "static/assets/modules/auth/transparent/icon-verification-code.png", "width": 1254, "height": 1254, "alpha": true, "bytes": 114061, "sha256": "e932900fa8ab0edcfeed0de0d52c637890e28f58f867aabfeacbb18df060a556", "provenance": "committed-binary", "rebuildable": false },
{ "id": "auth-title-divider", "output": "static/assets/modules/auth/transparent/title-divider.png", "width": 540, "height": 90, "alpha": true, "bytes": 6278, "sha256": "10a3281723abfadcc2cec7b8e4437fe50ecdc36d885d54ebce11f46c46ed4af4", "provenance": "committed-binary", "rebuildable": false },
{ "id": "agreement-checked", "output": "static/assets/modules/auth/transparent/agreement-checked.png", "width": 96, "height": 96, "alpha": true, "bytes": 10602, "sha256": "825fcb945c81330429abc1d3ec1d0c20df86a9634416e244896e452cb08644f4", "provenance": "committed-binary", "rebuildable": false },
{ "id": "agreement-unchecked", "output": "static/assets/modules/auth/transparent/agreement-unchecked.png", "width": 96, "height": 96, "alpha": true, "bytes": 6014, "sha256": "d9843d1c3644272f6eeff9fb44e763c857f409f404551a5e1f032ddf1b07b69f", "provenance": "committed-binary", "rebuildable": false }
]
}
@@ -0,0 +1,19 @@
{
"schemaVersion": 1,
"kind": "runtime-asset-inventory",
"scope": "retained-generated-assets",
"imports": [],
"assets": [
{ "id": "shared-scroll-primary-v3", "output": "static/assets/foundation/transparent/scroll-primary.png", "width": 1866, "height": 276, "alpha": true, "bytes": 576876, "sha256": "2181fece4d1743aa55b919c38ed1cb31662d8d2588f16e2dabb236e24d094040", "provenance": "committed-binary", "rebuildable": false },
{ "id": "shared-scroll-secondary-v3", "output": "static/assets/foundation/transparent/scroll-secondary.png", "width": 1866, "height": 300, "alpha": true, "bytes": 661030, "sha256": "52b8c16968e96cf457b227a3026ea9aed2b2a7b3d01ad56d409eb6b7504fc195", "provenance": "committed-binary", "rebuildable": false },
{ "id": "shared-scroll-toast-v3", "output": "static/assets/foundation/transparent/scroll-toast.png", "width": 1770, "height": 246, "alpha": true, "bytes": 468063, "sha256": "e08bdcb1ab3707762306bc23386f91562798d4efd5a5a917c3e9958fcfa7ddfc", "provenance": "committed-binary", "rebuildable": false },
{ "id": "shared-scroll-dialog-v3", "output": "static/assets/modules/auth/transparent/dialog-scroll.png", "width": 1860, "height": 1560, "alpha": true, "bytes": 355747, "sha256": "dfd0b122447e3a33737a4788efd7be35a2fca937ce600b1e8b14d12c7a9b77fa", "provenance": "committed-binary", "rebuildable": false },
{ "id": "genealogy-page-background-long", "output": "static/assets/modules/genealogy/opaque/genealogy-page-background-long.png", "width": 720, "height": 1800, "alpha": false, "bytes": 2347415, "sha256": "47e0711bced564190e7c5a28989a99f71677785f8e2d8d6cc52bcb014cba190d", "provenance": "committed-binary", "rebuildable": false },
{ "id": "tree-page-background-long", "output": "static/assets/modules/tree/opaque/tree-page-background-long.png", "width": 720, "height": 1800, "alpha": false, "bytes": 2320979, "sha256": "708523b34eb0fdb8a48125bda8f6502bd327b0ebf3bf8da874fa922e084f3b15", "provenance": "committed-binary", "rebuildable": false },
{ "id": "family-page-background-long", "output": "static/assets/modules/family/opaque/family-page-background-long.png", "width": 720, "height": 1800, "alpha": false, "bytes": 2330732, "sha256": "68e8b25335a30e44ec1208ee5db167c059f08309b2c6ec4e51a195190e3a5cee", "provenance": "committed-binary", "rebuildable": false },
{ "id": "records-page-background-long", "output": "static/assets/modules/records/opaque/records-page-background-long.png", "width": 720, "height": 1800, "alpha": false, "bytes": 2366470, "sha256": "232f9fa853041c4af0d55734b35185a43cec9856ba6277ffc8180ea09fb26040", "provenance": "committed-binary", "rebuildable": false },
{ "id": "notification-page-background-long", "output": "static/assets/modules/notification/opaque/notification-page-background-long.png", "width": 720, "height": 1800, "alpha": false, "bytes": 2159835, "sha256": "4519966d0a3c5a31f1e57aa716f810c97914e4cc81796e6b41931496edb0d05e", "provenance": "committed-binary", "rebuildable": false },
{ "id": "profile-page-background-long", "output": "static/assets/modules/profile/opaque/profile-page-background-long.png", "width": 720, "height": 1800, "alpha": false, "bytes": 2375398, "sha256": "e96a696a65a515417f5763592c48e23f75cca32fb651b76d495457c94ddb4f80", "provenance": "committed-binary", "rebuildable": false },
{ "id": "g01-empty-panel-frame", "output": "static/assets/modules/genealogy/transparent/empty-panel-frame.png", "width": 1122, "height": 1402, "alpha": true, "bytes": 74974, "sha256": "cce62602f9dc25cb9ff1ebf555efe20489513db55e2c5d0e3f762a6f11d7aa48", "provenance": "committed-binary", "rebuildable": false }
]
}
@@ -0,0 +1,11 @@
{
"schemaVersion": 1,
"kind": "runtime-asset-inventory",
"scope": "schema-v3",
"imports": [
"design-pipeline/manifests/auth-runtime-assets.json",
"design-pipeline/manifests/application-runtime-assets.json",
"design-pipeline/manifests/retained-generated-runtime-assets.json"
],
"assets": []
}
+10
View File
@@ -0,0 +1,10 @@
{
"name": "jiapu-design-pipeline",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "jiapu-design-pipeline"
}
}
}
+9
View File
@@ -0,0 +1,9 @@
{
"name": "jiapu-design-pipeline",
"private": true,
"type": "module",
"scripts": {
"validate:runtime-assets": "node scripts/validate-runtime-asset-inventory.mjs design-pipeline/manifests/runtime-assets.json",
"check": "npm run validate:runtime-assets"
}
}
@@ -0,0 +1,151 @@
import { createHash } from 'node:crypto'
import { readFile, readdir } from 'node:fs/promises'
import path from 'node:path'
const formalManifestKinds = new Set(['runtime-asset-inventory'])
const assertOnlyFields = (value, fields, label) => {
for (const field of Object.keys(value)) {
if (!fields.has(field)) throw new Error(`${label} contains unknown field: ${field}`)
}
}
const resolveInsideWorkspace = (workspace, candidate, label) => {
if (typeof candidate !== 'string' || candidate.trim() === '') {
throw new Error(`${label} must be a non-empty string`)
}
const absolutePath = path.resolve(workspace, candidate)
const relative = path.relative(workspace, absolutePath)
if (relative.startsWith('..') || path.isAbsolute(relative)) {
throw new Error(`${label} escapes workspace: ${candidate}`)
}
return absolutePath
}
const readManifest = async (manifestPath) => {
try {
return JSON.parse(await readFile(manifestPath, 'utf8'))
} catch (error) {
if (error.code === 'ENOENT') throw new Error(`missing manifest: ${manifestPath}`)
throw error
}
}
const validateDirectAsset = (asset, workspace) => {
if (!asset || typeof asset !== 'object' || Array.isArray(asset)) throw new Error('runtime asset must be an object')
assertOnlyFields(
asset,
new Set(['id', 'output', 'width', 'height', 'alpha', 'bytes', 'sha256', 'provenance', 'rebuildable']),
'runtime asset',
)
for (const key of ['id', 'output']) {
if (typeof asset[key] !== 'string' || asset[key].trim() === '') {
throw new Error(`runtime asset ${key} must be a non-empty string`)
}
}
resolveInsideWorkspace(workspace, asset.output, `${asset.id}.output`)
for (const key of ['width', 'height', 'bytes']) {
if (!Number.isInteger(asset[key]) || asset[key] <= 0) throw new Error(`${asset.id}.${key} must be a positive integer`)
}
if (typeof asset.alpha !== 'boolean') throw new Error(`${asset.id}.alpha must be boolean`)
if (asset.provenance !== 'committed-binary') {
throw new Error(`${asset.id}.provenance must be committed-binary`)
}
if (asset.rebuildable !== false) throw new Error(`${asset.id}.rebuildable must be false`)
if (!/^[a-f0-9]{64}$/.test(asset.sha256)) throw new Error(`${asset.id}.sha256 must be lowercase SHA256`)
return asset
}
export const expandRuntimeAssetInventory = async (rootManifestPath, workspace) => {
const workspacePath = path.resolve(workspace)
const loaded = new Set()
const stack = []
const ids = new Map()
const outputs = new Map()
const manifests = []
const addAsset = (asset, owner) => {
if (ids.has(asset.id)) {
throw new Error(`duplicate asset id: ${asset.id} (${ids.get(asset.id)}, ${owner})`)
}
if (outputs.has(asset.output)) {
throw new Error(`duplicate output: ${asset.output} (${outputs.get(asset.output).owner}, ${owner})`)
}
ids.set(asset.id, owner)
outputs.set(asset.output, { ...asset, owner })
}
// 递归展开只处理机器清单之间的依赖;页面消费者始终从源码扫描得到,避免双写。
const visit = async (manifestPath) => {
const absolutePath = resolveInsideWorkspace(workspacePath, manifestPath, 'manifest')
if (stack.includes(absolutePath)) {
throw new Error(`import cycle: ${[...stack, absolutePath].join(' -> ')}`)
}
if (loaded.has(absolutePath)) throw new Error(`duplicate import: ${absolutePath}`)
stack.push(absolutePath)
loaded.add(absolutePath)
manifests.push(absolutePath)
const manifest = await readManifest(absolutePath)
if (manifest.kind === 'runtime-asset-inventory') {
assertOnlyFields(manifest, new Set(['schemaVersion', 'kind', 'scope', 'imports', 'assets']), 'runtime manifest')
if (manifest.schemaVersion !== 1) throw new Error('runtime inventory schemaVersion must be 1')
if (typeof manifest.scope !== 'string' || manifest.scope.trim() === '') throw new Error('runtime inventory scope is required')
if (!Array.isArray(manifest.imports) || !Array.isArray(manifest.assets)) {
throw new Error('runtime inventory imports and assets must be arrays')
}
for (const asset of manifest.assets) addAsset(validateDirectAsset(asset, workspacePath), absolutePath)
for (const imported of manifest.imports) await visit(imported)
} else {
throw new Error(`unsupported manifest kind: ${manifest.kind}`)
}
stack.pop()
}
await visit(path.relative(workspacePath, path.resolve(rootManifestPath)))
return { manifests, assets: [...outputs.values()] }
}
export const validateRuntimeAssetRegistry = async (rootManifestPath, workspace, manifestsDirectory) => {
const workspacePath = path.resolve(workspace)
const directory = resolveInsideWorkspace(workspacePath, manifestsDirectory, 'manifests directory')
const rootManifest = await readManifest(path.resolve(rootManifestPath))
if (rootManifest.kind !== 'runtime-asset-inventory' || rootManifest.scope !== 'schema-v3') {
throw new Error('registry root scope must be schema-v3')
}
const inventory = await expandRuntimeAssetInventory(rootManifestPath, workspacePath)
const registered = new Set(inventory.manifests.map((manifest) => path.resolve(manifest)))
for (const asset of inventory.assets) {
let binary
try {
binary = await readFile(resolveInsideWorkspace(workspacePath, asset.output, `${asset.id}.output`))
} catch (error) {
if (error.code === 'ENOENT') throw new Error(`missing runtime asset: ${asset.output}`)
throw error
}
if (binary.length !== asset.bytes) {
throw new Error(`${asset.id}.bytes does not match ${asset.output}`)
}
const digest = createHash('sha256').update(binary).digest('hex')
if (digest !== asset.sha256) {
throw new Error(`${asset.id}.sha256 does not match ${asset.output}`)
}
}
// 顶层注册表必须覆盖目录内每一份正式 owner。这样新增清单若没有接入全局图会立即失败,
// output 与 id 的唯一性也就不再局限于某个业务域的 imports 闭包。
for (const entry of await readdir(directory, { withFileTypes: true })) {
if (!entry.isFile() || path.extname(entry.name).toLowerCase() !== '.json') continue
const manifestPath = path.join(directory, entry.name)
const manifest = await readManifest(manifestPath)
if (formalManifestKinds.has(manifest.kind)) {
if (!registered.has(path.resolve(manifestPath))) throw new Error(`unregistered manifest: ${manifestPath}`)
continue
}
throw new Error(`undeclared legacy manifest: ${manifestPath}`)
}
return inventory
}
@@ -0,0 +1,16 @@
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { validateRuntimeAssetRegistry } from './runtime-asset-inventory.mjs'
const scriptDirectory = path.dirname(fileURLToPath(import.meta.url))
const workspace = path.resolve(scriptDirectory, '..', '..')
const manifestArgument = process.argv[2]
if (!manifestArgument) throw new Error('Usage: node validate-runtime-asset-inventory.mjs <workspace-relative-manifest>')
const inventory = await validateRuntimeAssetRegistry(
path.resolve(workspace, manifestArgument),
workspace,
path.join(workspace, 'design-pipeline', 'manifests'),
)
process.stdout.write(`RUNTIME ASSET CHECK PASS manifests=${inventory.manifests.length} assets=${inventory.assets.length}\n`)
-18
View File
@@ -1,18 +0,0 @@
# A-01 Runtime Visual QA
- Source visual truth: `docs/design/screens/A01-启动登录引导-栅格验收-412x915.png`
- Target state: `412 × 915`, agreement unchecked.
- Static contract: passed on 2026-07-13.
- Runtime screenshot: current `412 × 915` capture accepted by the user on 2026-07-13 as the A-01 visual baseline.
## Evidence
- The accepted `412 × 915` capture preserves a continuous paper-and-ink background, complete button frames, the intended entry hierarchy and a single-line agreement row.
- The user explicitly accepted this capture as the current A-01 visual baseline; no additional visual tuning is authorized in this phase.
## Accepted scope
- This result accepts A-01's current visual baseline only.
- Existing local behavior remains unchanged: agreement selection enables the A-02 login route; other entries remain local prepared-state prompts.
final result: passed
-34
View File
@@ -1,34 +0,0 @@
# 家谱 App 项目计划
## 已确定的产品规则
- 端:uni-app 用户端,面向 Android 与 iOS 的 HBuilderX 打包。
- 导航:家谱、家族、我的;通知入口只做提醒与跳转。
- 主流程:管理员创建家谱并录入首代关系,族人申请加入后由管理员审核。
- 访问规则:家谱默认仅成员可见;管理员可开放为可搜索、可申请加入。
- 视觉:家祠卷轴——朱砂红、宣纸暖白、黛青、鎏金细线、竖式人物牌位。
- 素材:自制图标均为透明背景 PNG;必要时采用可商用在线 PNG 图标并统一风格。
- 接口:`APP.openapi.yaml` 是 App 端接口契约;地址、`clientid``tenantId` 与测试账号通过本地配置注入。
## 进度
- [x] 阅读思维导图、现有界面稿、参考 uni-app 工程与 OpenAPI 文档。
- [x] 确定三 Tab 信息架构、核心创建流程、加入审核规则和视觉方向。
- [x] 创建 uni-app 工程骨架、设计令牌与透明 PNG 图标资产。
- [x] 完成登录、我的家谱、创建家谱、家谱总览、世系树、成员档案和申请审核的首版界面与本地交互闭环。
- [x] 实现登录、我的家谱、创建家谱与公开家谱申请加入。
- [x] 修复 Vue 3 工程入口:补齐根目录 `index.html`,并加入配置与入口自检。
- [x] 完成当前已实现页面的 HBuilderX Web 编译、全量语言诊断与缓存排查;本地 H5 发行构建仍受 HBuilderX 登录状态限制。
- [x] 参照完整 uni-app 工程修复 Sass 注入:令牌直接置于 `uni.scss`,重启 Vite 后登录页样式请求已验证为 200。
- [x] 完成显式 Mock/Remote 模式、密码登录链路、token 保存、接口 `data` 解包与当前家谱 ID 上下文。
- [x] 完成创建家谱后录入首代,并按当前家谱加载详情、世系树、成员档案、申请审核与通知已读。
- [x] 完成家族动态发布、谱文/相册/祭祀/族务内容入口,以及动态个人资料读取。
- [ ] 实现家谱总览、世系树、成员资料和亲属关系编辑。
- [ ] 实现家族圈、谱文、相册、祭祀、贺礼、成长记录、备忘和功德。
- [ ] 实现通知、个人中心、帮助、反馈、VIP 与接口配置。
- [ ] 在 HBuilderX 验证运行、Android/iOS 打包配置和关键流程。
## 当前接口限制
- 已支持:认证、家谱、成员、字辈、世系树、家族圈、文章、相册、祭祀、族务记录、通知、反馈与 VIP。
- 暂不实现为可用功能:邀请码、视频与多管理员分级权限;接口文档尚未提供对应端点。
-28
View File
@@ -1,28 +0,0 @@
# 参考 uni-app 工程结构核对
参考工程:`C:\Users\Rain\Desktop\软件\JOB\3dyjsapp`
## 已确认并采纳
- 参考工程是完整的 HBuilderX uni-app 工程,根目录使用 `App.vue``main.js``pages.json``manifest.json``index.html``uni.scss`
- 参考工程的 `manifest.json` 已声明 Vue 3`main.js` 同时保留 Vue 2 与 Vue 3 的条件入口。家谱项目固定使用 Vue 3,因此保留精简的 Vue 3 入口即可。
- 参考工程的 `uni.scss` 直接定义 Sass 变量,而不是在其中使用相对路径导入。家谱项目已按此方式调整:`uni.scss` 是唯一的设计令牌来源。
- 参考工程把页面清单集中在 `pages.json`,家谱项目已用自动审计校验每条路由都有对应页面文件。
## 本项目的对应实现
- `uni.scss`:朱砂红、宣纸暖白、墨色、鎏金等 Sass 令牌。
- `styles/global.scss`:只由 `App.vue` 从根目录引入的全局通用样式。
- `tests/compile-audit.ps1`:校验路由、入口、Sass 引用与模块引用。
- `tests/uni-scss-injection.ps1`:防止在 `uni.scss` 中再次写入会被页面按错误目录解析的相对 Sass 导入。
## 不照搬的部分
- 彩票业务页面、接口、数据模型与大量业务组件。
- 参考工程中的第三方 UI 模块、OAuth 配置和过宽设备权限;家谱 App 按实际功能逐项添加,避免无关权限与凭据进入工程。
## 后续仍需补齐
- 家谱 App 的 Android/iOS 应用图标尺寸与启动图资源。
- 登录后的真实接口配置与测试账号联调。
- HBuilderX 登录后的本地 H5 发行构建及 Android/iOS 云打包验证。
@@ -1,82 +0,0 @@
# A-01 启动 / 登录引导设计记录
> 状态:A-01 文字注册入口方案、四尺寸静态验收、本地 UI 与 412 × 915 运行时视觉基准已完成并获用户验收;A-02 双标签本地 UI 已完成。协议页去向、A-03 至 A-06 的设计、认证接口与 Android 全尺寸回归验收仍未完成,D2 继续进行中。
>
> 本页设计事实的唯一归档位置为本文件。旧的 `2026-07-13-a01-login-guide-design.md` 与 `2026-07-13-a01-login-guide.md` 仅保留为失效历史,不可作为实现或推进下一页的依据。
## 1. 已归档的美术锚点
- 文件:[`screens/A01-启动登录引导-效果稿-v1-显式协议.png`](screens/A01-启动登录引导-效果稿-v1-显式协议.png)
- 效果稿 v2[`screens/A01-启动登录引导-效果稿-v2-独立注册.png`](screens/A01-启动登录引导-效果稿-v2-独立注册.png),记录此前“四个独立按钮”的尝试;该入口结构已失效,不作当前方案依据。
- 当前效果稿 v3[`screens/A01-启动登录引导-效果稿-v3-文字注册入口.png`](screens/A01-启动登录引导-效果稿-v3-文字注册入口.png),保留第 2 节的入口层级归档;它不再定义运行时像素坐标。
- 当前运行时视觉基准:[`screens/A01-启动登录引导-栅格验收-412x915.png`](screens/A01-启动登录引导-栅格验收-412x915.png),用于 A-01 协议未勾选状态的同尺寸像素比对;`pages/auth/entry.vue` 必须使用真实页面结构还原,不得将整张效果图作为页面内容。
- 用途:保留本轮已确认的“家祠卷轴”欢迎页美术方向,不作为可直接还原的代码布局图,也不作为功能图标资产。
- 画面方向:朱砂家祠红头、暖宣纸主体、古金细线与云纹、墨褐标题、两侧淡墨建筑/竹影与底部山水。登录欢迎页后续必须使用独立的红头/宣纸背景组合,不复用 G01 的列表页大山水底图。
## 2. 已确认的页面结构与交互
- 标题区保留“家谱 / 为家族留存可传承的记忆”,使用美术锚点中的朱砂家祠红头、暖宣纸、古金云纹、墨褐文字与淡墨建筑/竹影层次。
- 操作区只保留两个大按钮和一个文字入口:朱砂主按钮“登录”、古金描边按钮“微信登录”,以及其下方的“还没有账号?注册账号”。“注册账号”仅为朱砂红可点击文字,不再作为独立大按钮。
- 入口顺序:登录为主入口;微信登录为次入口;注册账号为辅助文字入口。
- 已勾选协议后,“登录”前往 `A-02`,且 `A-02` 内固定使用“账号密码登录 / 手机验证码登录”双标签切换,默认显示账号密码登录。微信授权中、取消与失败回跳状态仍归 `A-03`,在 A-03 设计完成前当前入口只提示“微信登录功能准备中”。
- 注册账号入口仍归 `A-04`;忘记密码仍归 `A-05`。这两页完成设计前,当前入口只提示页面准备中,不伪造跳转。
- 协议必须为用户主动勾选:初始未勾选,可分别打开《家谱用户协议》和《隐私政策》。不得使用“登录即表示同意”或任何默认同意文案;未勾选时登录操作应明确提示用户先确认协议。
## 3. 固定布局与状态边界
- 页面采用纵向滚动;`320 × 568` 下两个按钮、注册文字和协议行可以滚动,但任一按钮文字、注册文字、协议行和底部内容不得被系统导航区遮挡。
- A-01 的登录与微信功能图标均需采用独立本地透明 PNG;微信标志只使用官方或许可来源的原色透明 PNG,不自行重绘或改色。账号密码与手机号验证码的表单图标归 A-02 设计时确定。
- A-01 只设计欢迎、入口与协议未勾选提示。账号密码与手机号验证码表单均归 `A-02`,微信授权结果归 `A-03`,注册归 `A-04`,忘记密码归 `A-05`
- A-01 正常态必须展示两个按钮、注册文字入口和未勾选协议;点击任一需要授权的入口但未勾选协议时,仅提示“请先阅读并同意相关协议”,不视为用户已同意。
- 页面根容器宽度必须为 100%,不可写死为 `750rpx` 或使用整张效果图。固定设计画布仍以 `750rpx` 为测量基准,运行时由真实 DOM 按当前屏宽响应式还原。
#### 412 × 915 运行时校准表
| 元素 | 412 × 915 像素基准 | 750rpx 实现基准 |
| --- | --- | --- |
| 朱砂家祠头图 | 高 139px | 高 253rpx |
| 红头谱印 | 比头图顶边低约 38px | `top: 70rpx` |
| 标题区起点 | 红头后约 82px | `padding-top: 150rpx` |
| 标题两侧云纹 | 紧贴标题两侧,不扩张标题组宽度 | 独立透明素材 `auth-title-cloud-v1.png``64rpx × 40rpx`,标题左右各 12rpx |
| 标题分隔饰线 | 宽约 234px,中心为对称小结 | 宽 426rpx;中结使用独立透明素材 `auth-divider-knot-v1.png`,上、下间距独立 |
| 两个按钮 | x=60px,宽约 291px,高约 65px | `margin: 95rpx 110rpx 0`,高 118rpx |
| 两按钮间距 | 约 28px | `margin-top: 51rpx` |
| 按钮内框与内容组 | 内框距外边约 3px,四角有卷草角花;图标与文字整体偏左 | `inset: 6rpx`;四角使用同一张 `auth-button-corner-v1.png` 镜像;内容组 `translateX(-20rpx)` |
| 注册入口 | 微信按钮后约 56px;无左右短线 | `margin-top: 98rpx` |
| 协议行 | 注册入口后约 77px;复选框与全部文案同一水平线 | `margin-top: 141rpx`,字号与行高均为 20rpx / 30rpx,复选框 30rpx`align-items: center``white-space: nowrap`,加安全区 |
- 上表是 A-01 运行时布局数值的唯一所有者。320 × 568 时页面允许纵向滚动,根容器不得以 `overflow: hidden` 截断协议行或系统安全区;底部内边距包含 `env(safe-area-inset-bottom)`
- 仅作静态设计验证,不代表 uni-app 运行时验收;页面实现后仍须在 Android 320 × 568、360 × 640、360 × 800、412 × 915 上重新验收。
| 状态 | 触发 | A-01 可见结果 | 去向 |
| --- | --- | --- | --- |
| 初始未勾选 | 首次进入 | 两个按钮、注册文字与空复选框 | 留在 A-01 |
| 未同意协议 | 点击登录、微信登录或注册账号 | 提示“请先阅读并同意相关协议”,不跳转、不改变勾选状态 | 留在 A-01 |
| 登录已确认协议 | 点击“登录” | 结束 A-01 引导 | A-02,默认账号密码登录标签 |
| 微信已确认协议 | 点击“微信登录” | 提示“微信登录功能准备中” | 留在 A-01,等待 A-03 设计完成 |
| 注册已确认协议 | 点击“注册账号”文字 | 提示“注册页面准备中” | 留在 A-01,等待 A-04 设计完成 |
### A-01 资产清单
| 资产 | 用途 | 来源或生成提示 | 尺寸与验收 |
| --- | --- | --- | --- |
| `docs/design/screens/A01-启动登录引导-效果稿-v3-文字注册入口.png` | A-01 视觉比对依据 | 已归档并由用户确认的 v3 效果稿 | `852 × 1846px` PNG;仅作真实 DOM 页面还原和运行时验收对照,不直接作为页面内容 |
| `static/assets/backgrounds/auth-ancestral-header-v1.png``auth-rice-paper-v1.jpg``auth-ink-scenery-v1.png` | A-01 分层页面背景 | 2026-07-13 内置图像生成 | 当前 A-01 使用;不得复用 G-01 背景 |
| `static/assets/icons/auth/login-outline-v1.png``static/assets/icons/auth/wechat-licensed-v1.png` | A-01 两个按钮图标 | 登录图标复用项目既有资产;微信图标来源与许可见旧记录 | 当前 A-01 使用;微信图标免费使用须在应用“关于/设置”及应用商店描述中保留 Icons8 署名链接 |
| `static/assets/icons/auth/auth-button-corner-v1.png` | 两个入口按钮的四角卷草装饰 | 2026-07-13 根据 412 × 915 视觉基准生成,去绿幕后保存 | `160 × 160px` 透明 PNG;同一张素材镜像至四角 |
| `static/assets/icons/auth/auth-divider-knot-v1.png` | 标题上下金线的中心对称中结 | 2026-07-13 根据 412 × 915 视觉基准生成,去绿幕后保存 | `160 × 96px` 透明 PNG;不得改用长祥云 |
| `static/assets/icons/auth/auth-title-cloud-v1.png` | “家谱”标题左右祥云 | 2026-07-13 根据 412 × 915 视觉基准生成,去绿幕后保存 | `200 × 120px` 透明 PNG;右侧只允许镜像该素材 |
## 4. 静态设计验收与剩余缺口
- 静态设计验收已覆盖 320 × 568、360 × 640、360 × 800、412 × 915;四个尺寸均保持两个大按钮与单行文字注册入口。`412 × 915` 图是 A-01 协议未勾选状态的运行时像素基准;A-01 本地页以真实 DOM 还原其层级和比例,运行时仍须截图对比,不得以整图页面替代。
- 归档文件:[320 × 568](screens/A01-启动登录引导-栅格验收-320x568.png)、[360 × 640](screens/A01-启动登录引导-栅格验收-360x640.png)、[360 × 800](screens/A01-启动登录引导-栅格验收-360x800.png)、[412 × 915](screens/A01-启动登录引导-栅格验收-412x915.png)。
- 此结论只验证设计稿与固定栅格,不替代 uni-app 页面实现后的 Android 运行时验收。
- 2026-07-13 用户已验收当前连续背景、完整按钮边框与单行协议区的 `412 × 915` 运行截图,A-01 当前视觉基准标记为完成;本轮不再授权继续微调视觉细节。
- 页面实现阶段仍须按本记录在 Android 实机或同等运行环境复核四个尺寸的安全区、滚动与文字折行。
- A-01 的协议最终页面去向及 A-03 至 A-06 的设计仍未完成;这些后续范围不影响当前 A-01 视觉基准的验收结论。
## 5. 推进约束
- `A-01``A-02` 的本地 UI 已按本记录及实现合同落地;A-02 的最终效果稿与 Android 运行时验收仍待补齐,不能据此推进 A-03。
- 当前 A-01 视觉基准已完成;后续只在协议页面、认证接口或新页面范围获得明确授权后继续推进。
- 用户已于 2026-07-13 授权落地 A-01 欢迎入口与 A-02 双标签登录页的本地 UI;仍不接接口、不执行真实登录、不改 Android 打包配置。实现细节的唯一所有者见 `docs/superpowers/specs/2026-07-13-a01-a02-static-ui-implementation-design.md`
@@ -1,69 +0,0 @@
# D1 安卓视觉地基规范
> 状态:P-00 实施中。
> 本文件是 Android 页面视觉、资产与公共组件的唯一施工规范;与历史设计记录冲突时,以本文件和用户最新确认优先。
## 1. 冻结视觉锚点
- A01 启动/登录引导:`screens/A01-启动登录引导-栅格验收-412x915.png`
- G01 我的家谱:`screens/G01-我的家谱-紧凑版设计稿-v5-Tabbar安全区.png`
两页已获用户验收。P-00 可以重整文件、组件和资产路径,但在 412 × 915 下不得改变两页可见布局、色彩、纹样、按钮或背景组合。
## 2. 视觉语言
| 元素 | 规则 |
| --- | --- |
| 页面基调 | 暖宣纸、朱砂、古金、墨褐、淡墨山水/竹影;禁止绿色页面背景。 |
| 文字 | 标题与重要文字用楷体语气;正文首先保证 Android 小屏可读性。 |
| 主操作 | 朱砂完整按钮图片,含自身纹理、边框与四角;不可由 CSS 色块和零散图片拼装。 |
| 次操作 | 纸白完整按钮图片,含自身金边、四角与纹理;不可借用主按钮角标。 |
| 页面背景 | 使用完整不透明纸纹/页头/面板图片,必要时可叠加透明山水或竹影 PNG;不能使用截图拼接。 |
| 装饰 | 云纹、谱印、分隔纹、图标、边框均为透明 PNG;不使用 CSS/SVG 临时绘制。 |
## 3. 资产分类与目录
所有最终资产放在 `static/assets/`,路径必须表达所属范围、背景属性和语义:
```text
static/assets/
foundation/
opaque/ # 自带底色/纹理:纸纹、页头、完整按钮、卡片、导航
transparent/ # 透明 PNG:Logo、图标、云纹、边框、分隔纹
modules/
genealogy/
opaque/
transparent/
tree/
opaque/
transparent/
family/
opaque/
transparent/
```
- `opaque` 资产不能依赖页面 CSS 背景色才显示完整外观。
- `transparent` 资产必须是 RGBA PNG;其使用位置要在资产清单中写明宿主不透明底图。
- 每项资产都在 P00 清单中登记用途、显示尺寸、引用组件、引用页面和保留/删除状态。
- 不再创建长期使用的 `-v1``-v2``-source``-copy` 文件;同一语义只保留一个已验收版本。
## 4. 页面与组件
- 页面文件:`<页面编号>-<语义名>.vue`;没有确认页面编号的临时骨架要标明 `skeleton`,不能伪装成完成页面。
- 公共组件只收纳已被两个或以上确认页面使用的稳定结构:页面纸纹壳、顶部栏、底部导航、完整图片按钮、完整图片卡片、状态容器。
- 页面与组件开头写中文用途注释;模板按背景、页头、主体、操作、状态分段;资产图层和复杂交互写中文约束说明。
- CSS 只负责布局、尺寸、文字、可见/隐藏与交互状态;不能再用 CSS 绘制边角、金线、云纹、分隔结、纸纹、按钮或卡片外观。
## 5. Android 约束
- 核验尺寸:320 × 568、360 × 640、360 × 800、412 × 915。
- 正文不小于 24rpx;主操作最小高度 88rpx;可点击区不小于约 44dp。
- 使用 Flex、边距和基础布局;不依赖 CSS Grid、`flex gap`、复杂滤镜、大面积动画或全屏截图背景。
- 顶部和底部预留状态栏、三键/手势导航安全区;固定底栏不得遮挡页面操作。
## 6. 验收和代码纪律
- 资产先验收,页面后验收;资产不通过不进入页面。
- 所有页面只实现明确设计与交互;不增加推测性的 JS、接口、依赖、变量、方法和分支。
- 每次结构迁移必须同时更新页面路径、`pages.json`、跳转、测试和文档;旧路径确认无引用后立即删除。
- 每次完成后执行受影响审计、编译检查与 `git diff --check`A01、G01 还需做截图回归。
@@ -1,134 +0,0 @@
# G-01:我的家谱设计记录
> 状态:设计验收完成(2026-07-13,用户确认)
> 美术锚点:[G01 我的家谱美术锚点](references/G01-我的家谱-美术锚点.png)
> 布局锚点:[D1 安卓视觉规范与页面壳](D1_安卓视觉规范与页面壳.md#62-g-01-固定设计栅格360--800dp)
## 页面目标
让已登录用户一眼查看当前家谱、进入四个高频入口、切换已创建 / 已加入家谱。无家谱的创建或搜索加入状态归属 `G-02`
## 背景组合
- 红头:`static/assets/backgrounds/header-hall-lineart.png` 叠加朱砂底色。
- 内容纸面:`static/assets/backgrounds/paper-rice-texture.jpg`
- 页面底部:`static/assets/backgrounds/footer-mountain-bamboo.png`,只用于列表尾部与新建按钮附近。
## 图标资产清单
所有项目完成后均为独立、紧凑的 RGBA 透明 PNG;不能从大图裁切后直接使用。
| 区域 | 语义 | 目标文件 | 风格 | 状态 |
| --- | --- | --- | --- | --- |
| 顶部左侧 | 项目 Logo | `brand/jiapu-seal-logo.png` | 用户确认的家祠 Logo 原图 | 已确认使用;待页面小尺寸摆放验收 |
| 顶部右侧 | 通知 | `common/notice-v3.png` | 古金双线 + 轻云纹 | 已生成;`70 × 94px` 主体通过紧凑尺寸检查 |
| 列表入口 | 右箭头 | `common/chevron-right-v2.png` | 古金简洁单线 | 已生成;`96 × 96px`、RGBA 四角透明、48px 检查通过 |
| 快捷入口 | 世系图 | `genealogy/tree-v2.png` | 古金双线简化版 | 已生成;`96 × 96px`、RGBA 四角透明、48px 检查通过 |
| 快捷入口 | 家族成员 | `genealogy/members-v2.png` | 古金双线简化版 | 已生成;`96 × 96px`、RGBA 四角透明、48px 检查通过 |
| 快捷入口 | 字辈诗 | `genealogy/generation-poem-v2.png` | 古金双线简化版 | 已生成;`96 × 96px`、RGBA 四角透明、48px 检查通过 |
| 快捷入口 | 申请审核 | `genealogy/application-v2.png` | 古金双线简化版 | 已生成;`96 × 96px`、RGBA 四角透明、48px 检查通过 |
| 主操作 | 新建家谱 | `action/add-v2.png` | 古金双线 + 轻云纹 | 已生成;`96 × 96px`、RGBA 四角透明、48px 检查通过 |
| 底部 Tab | 家谱 | `tab/genealogy-v4.png``genealogy-active-v4.png` | 简洁谱册 / 世系语义 | 已生成;同轮廓墨褐 / 朱砂成对导出,`96 × 96px`、48px 检查通过 |
| 底部 Tab | 家族 | `tab/family-v4.png``family-active-v4.png` | 简洁双人群组语义 | 已生成;同轮廓墨褐 / 朱砂成对导出,`96 × 96px`、48px 检查通过 |
| 底部 Tab | 我的 | `tab/profile-v4.png``profile-active-v4.png` | 简洁人物档案语义 | 已生成;同轮廓墨褐 / 朱砂成对导出,`96 × 96px`、48px 检查通过 |
## 页面完成条件
- [ ] 图标资产清单全部生成并在 `46rpx` / `24dp` 下逐个检查。
- [ ] 背景组合在 `320 × 568` 下不遮挡标题、按钮和列表。
- [ ] 按固定栅格完成正常态、无家谱态与加载态设计。
- [ ] 不存在横线占位、重复家谱层级或无业务含义的颜色差异。
## Tabbar 重设计规则
- 当前 `static/assets/icons/tab/``static/icons/tab-*.png` 的图标不通过验收,不得使用。
- 三个 Tab 重新采用等视觉重量的简洁符号:家谱为“世系关系 / 谱册”语义,家族为“双人群组”语义,我的为“人物档案”语义。
- 三个图标同为 `24dp` 标准视口、无文字嵌入、无枝叶和云纹等细碎装饰;默认态为墨褐 / 古金描边,选中态为朱砂色的同一图形加粗或填充。
- Logo 是品牌标识,不能拿来代替家谱 Tab 图标;Tab 图标只服务于快速识别目的地。
## 2026-07-11 当日确认记录
- 当前交接视觉稿:[G01-我的家谱-紧凑版设计稿-v5-Tabbar安全区.png](screens/G01-我的家谱-紧凑版设计稿-v5-Tabbar安全区.png)。它是明日施工时的美术与布局锚点,不替代真实页面和资产验收。
- 顶部品牌使用项目原有 `static/assets/icons/brand/jiapu-seal-logo.png`;缩小后固定在左上安全区,避免与居中标题及殿宇线稿争夺视觉焦点。
- 底部 Tabbar 仍然贴合屏幕底部;三个图标和文字整体上移至 Android 手势/三键导航安全区内,标签“家谱 / 家族 / 我的”加大,任何屏幕尺寸下不得被底边裁切。
- 上述确认只锁定 G-01 的方向和版式。真实透明 PNG 图标、背景组合、320 × 568 小屏校验、空状态与加载状态尚未完成,因此 **G-01 仍不勾选为设计完成**
- 明日从 G-01 的可施工项开始:先按 D1 规范落实真实图标与背景资产、再还原页面布局;全量页面设计完成前,不恢复接口联调或 Android 打包工作。
## 2026-07-12 施工记录
- 已完成 G-01 独立图标资产:四个快捷入口、主操作、列表右箭头,以及三组 Tab 默认 / 选中态;全部为 `96 × 96px` RGBA PNG,四角透明,并按实际 `48px` 显示尺寸检查。
- 已完成 G-01 首轮页面还原:朱砂祠堂红头、左上原 Logo、条件式消息圆点、宣纸与山水背景、当前家谱题签、快捷入口、已创建 / 已加入列表、空状态、加载状态和 Android 安全区 Tabbar。
- 本页暂时使用 `data/mock.js` 的展示数据;已移除 G-01 的远程接口调用,接口联调仍按总规划暂停。
- 已通过:`g01-visual-contract.ps1``compile-audit.ps1``uni-scss-injection.ps1``vue3-entry.ps1`
- 待完成:在已打开的 HBuilderX 中按 `360 × 800``320 × 568` 预览并记录无重叠、无底部裁切的证据。完成前不勾选本页“设计完成”条件。
## 2026-07-12 视觉校正记录
- 用户预览反馈首轮还原与确认稿差异过大。已定位为:祠堂线稿直接铺在红头导致视觉过满,且题签 / 列表使用普通圆角边框而不是确认稿的纸签资产。
- 红头已改为纯朱砂底 + 独立 `header-hall-lineart.png` 低对比背景层(不透明度 `0.22`);Logo 放大并上移,标题与通知保持独立层级。
- 已新增并接入 `backgrounds/genealogy-current-slip-frame.png``backgrounds/genealogy-list-slip-frame.png`;两者为透明 PNG 双金线纸签框,透明角检查通过。
- 此次只修正可见还原偏差,不改变 G-01 的数据、路由和状态范围;仍待用户重新运行后的两组小屏预览确认。
## 2026-07-12 尺寸校正记录
- 原始品牌 Logo 的透明留白导致顶部显示偏小,已从同一原图无损裁切为 `brand/jiapu-seal-logo-header.png` 后接入;不改变 Logo 的图形、配色或品牌含义。
- 快捷入口图标由 `52rpx` 调整为 `68rpx`Tab 图标由 `48rpx` 调整为 `56rpx`,标签由 `24rpx` 调整为 `28rpx`,使实际显示尺寸靠近确认稿。
- 校正后已重新通过 G-01 资产/页面契约、编译审计和 SCSS 注入审计;仍需 HBuilderX 截图作最终视觉依据。
## 2026-07-12 第三轮背景与栅格校正
- 红头高度由 `216rpx` 收至 `184rpx`,改用 `backgrounds/header-cinnabar-texture-v2.jpg`;祠堂线稿仅作为 `0.16` 不透明度的独立底纹层。
- 内容左右边距由 `32rpx` 调整为 `48rpx`,当前家谱题签高度收至 `250rpx`,并在快捷入口和列表之间恢复 `backgrounds/genealogy-section-divider-v2.png` 金线云纹分隔。
- 页面底色改用浅宣纸 `backgrounds/paper-rice-texture-v2.jpg`;山水竹影改用透明 `backgrounds/footer-mountain-bamboo-v2.png` 作为整页底部淡层,不再出现矩形背景块;已检查不含绿色像素。
- 标题、分区标题与家谱名称增加宋/楷书本机回退链;题签框统一压为古金 `#B58A4B`
- 已通过:G-01 页面/资产契约、编译审计、SCSS 注入审计及页脚透明区域检查。仍待新截图比对,页面不标记为完成。
## 2026-07-12 第四轮底图与题签边角校正
- 根因核验:`genealogy-current-slip-frame.png``genealogy-list-slip-frame.png` 的四个角均为 Alpha 0;白色直角边并非 PNG 资产本身,而是题签容器在透明镂空角下铺了接近白色的矩形底色。
- 已移除当前家谱题签和两条列表题签的矩形底色,让透明镂空角直接透出宣纸底纹;文字、边框和点击区域不变。
- 已撤回错误的 `footer-mountain-bamboo-v2.png`(该图是连续实心灰山,无法还原设计稿的淡墨层次),恢复使用带亭、远山和竹影的 `footer-mountain-bamboo.png`,以 `0.48` 透明度贴在 Tabbar 上沿,不形成独立灰色矩形带。
- `tests/g01-visual-contract.ps1` 先新增回归断言并确认旧实现失败,再完成最小改动后通过;仍需在 HBuilderX 的同一设备尺寸下由新截图确认最终观感,G-01 不标记为完成。
## 2026-07-12 第五轮信息层级与谱印施工
- 施工依据固定为 `screens/G01-我的家谱-紧凑版设计稿-v5-Tabbar安全区.png`,对应用户箭头指出的谱印、元信息、快捷入口、两条列表和新建按钮;未改接口、路由、页头、背景或 Tabbar。
- 新增并接入透明 PNG`common/location-v1.png``common/member-meta-v1.png``common/admin-v1.png``genealogy/seal-current-frame-v1.png``genealogy/seal-row-frame-v1.png``action/create-cloud-v1.png`。谱印框只承载朱砂和金白边框,谱印固定显示“家谱”,家谱名称和数值仍为动态文本。
- 当前家谱改为地点、成员数、管理员三组图标化元信息;快捷入口显示框提升至 `82rpx`;新建按钮加入两侧云纹 PNG。
- 家谱列表改为谱印框、标题、地点/成员元信息和“更新于”日期的两层结构;`320px` 小屏隐藏更新时间,优先保证标题、元信息、角色和箭头不重叠。
- 已新增资产透明角/有效图形审计。验证通过:`g01-visual-contract.ps1``g01-asset-alpha-audit.ps1``compile-audit.ps1``uni-scss-injection.ps1`。待 HBuilderX 新截图与参考稿并排确认后,G-01 才能标记设计完成。
## 2026-07-12 第六轮参考稿字段与右侧基线校正
- 预览字段已切换为参考稿:当前家谱“汤氏家谱 / 河南·洛阳 / 158 位成员 / 管理员”,已加入家谱“汤氏宗谱 / 山东·济宁 / 286 位成员 / 成员”;不再使用四川、达州、刘氏等旧演示字段。
- 当前题签恢复标题与元信息之间的细金线;短字段确保 430px 验收宽度内三组元信息同一行显示。
- 列表谱印固定为竖排“家谱”;右侧角色与箭头改为同一水平基线;“更新于”保留为文本,移除参考稿中不存在的日历图标和对应闲置资产。
- 新增字段/基线契约先失败后通过。完整验证再次通过:G-01 视觉契约、PNG 透明角审计、编译审计、SCSS 注入审计、Vue 3 入口审计。仍待用户提供 HBuilderX 新截图完成视觉验收。
## 2026-07-13 第七轮结构、屋檐与祥云校正
- 当前家谱题签由“谱印 + 右侧文案列”改为两层结构:上层为谱印和名称,下层为金线分隔后的地点、成员数、管理员三组元信息;三组元信息不再受谱印列宽挤压。
- 家谱列表由独立的左侧内容列与右侧操作列改为单一主内容区:第一行是标题、角色与箭头,第二行是地点、成员数与“更新于”日期;`320px` 宽度仅隐藏日期,保留其余信息。
- 根页头收窄至 `164rpx`;放大并提高现有祠堂屋檐线稿的可见度,保持其位于 Logo 和标题之后。Tabbar 容器收窄,图标和标签同步提升视觉重量。
- 内容左右边距收至 `32rpx`,快捷入口到列表的无效留白减少;山水竹影扩展至两条列表和新建按钮后方,仍低于所有文字和操作层。
- `action/create-cloud-v1.png` 已由独立透明 PNG `action/create-cloud-v2.png` 替换;v2 使用项目生成的古金线描祥云,四角透明且有效图形审计通过,旧资产已移除。
- 已通过:更新后的 G-01 视觉契约、PNG 透明审计和编译审计。仍待 HBuilderX 的 `360 × 800``320 × 568` 截图与 v5 参考稿并排确认,G-01 不标记为设计完成。
## 2026-07-13 第八轮预览字号校正
- 根据 HBuilderX 预览,列表第二行的地点、成员数与更新时间分别提升至可读字号,元信息图标同步放大;`320px` 继续只隐藏更新时间。
- 新建家谱按钮保留单行文字:缩小云纹和加号的占位宽度,并为按钮文字设置不换行约束,避免“新建家谱”折为两行。
- 已通过更新后的 G-01 视觉契约与编译审计;仍待下一张 HBuilderX 截图复核实际观感。
## 2026-07-13 第九轮顶部状态栏兼容
- 用户确认根页 `124rpx` 的可视朱砂导航高度符合视觉稿;不再扩大其内容区。
- 根页头总高度改为可视导航高度加 `var(--status-bar-height, 0px)`,并以相同变量作为顶部内边距;状态栏不会压缩 Logo、标题和通知入口。
- 宣纸纸纹起点使用同一计算高度,避免状态栏存在时红头结束处出现错层。
- Android App 配置朱砂状态栏背景和浅色系统图标;仍需在有刘海、无刘海与三键导航 Android 真机复核。
## 2026-07-13 最终视觉验收
- 用户已提供 `360 × 800` 正常页、`320 × 568` 顶部与滚动到底部的响应式预览截图:顶部、题签、四个快捷入口、两条列表、新建按钮、山水与 Tabbar 均无重叠;`320 × 568` 下新建按钮可完整滚动至 Tabbar 上方。
- 用户已提供 HBuilderX Web 浏览器中 `Pixel 2 XL` 的常规屏预览:页面内容、山水和 Tabbar 均无裁切。
- 用户于 2026-07-13 明确确认“没事你标记吧可以了”,据此将 G-01 标记为设计验收完成。该确认不等同于 Android 原生包、真机刘海/三键导航或接口联调验收,以上工作仍按总规划暂停。
@@ -1,143 +0,0 @@
# P00 页面结构与资产清单
> 状态:页面与现有资产迁移已完成;A01 完整按钮候选待设计图复核,两页截图回归待办。
> 目的:让页面、路由和资产都有唯一、可读、可审计的归属;本文件是 P-00 的执行清单。
## 1. 页面命名规则
最终页面文件遵循 `<页面编号>-<语义名>.vue`
- 账户:`a01-entry.vue``a02-login.vue`
- 家谱:`g01-my-genealogies.vue``g03-create-genealogy.vue`
- 世系:`t01-tree-overview.vue``t03-member-profile.vue`
- 家族:`f01-family-feed.vue``f02-publish-feed.vue`
- 消息:`n01-message-center.vue`
- 我的:`m01-profile-home.vue`
尚未被全量设计确认、但为了保留现有功能骨架的通用页面,必须在文件名中使用 `skeleton`,不能使用含义不明的 `index.vue``list.vue``detail.vue`
## 2. 当前 15 个路由的迁移映射
| 当前路由 | 当前可确认用途 | 目标文件 | 处理状态 |
| --- | --- | --- | --- |
| `pages/auth/a01-entry` | A01 启动/登录引导 | `pages/auth/a01-entry.vue` | 已迁移;视觉冻结;完整按钮资产待补 |
| `pages/auth/a02-login` | A02 账号登录 | `pages/auth/a02-login.vue` | 已迁移;未验收 |
| `pages/genealogy/g01-my-genealogies` | G01 我的家谱 | `pages/genealogy/g01-my-genealogies.vue` | 已迁移;视觉冻结 |
| `pages/genealogy/g03-create-genealogy` | G03 创建家谱 | `pages/genealogy/g03-create-genealogy.vue` | 已迁移;未验收 |
| `pages/genealogy/g04-first-ancestor` | G04 始祖录入 | `pages/genealogy/g04-first-ancestor.vue` | 已迁移;未验收 |
| `pages/genealogy/g05-genealogy-overview` | G05 家谱总览 | `pages/genealogy/g05-genealogy-overview.vue` | 已迁移;未验收 |
| `pages/genealogy/g06-search-genealogies` | G06 公开家谱搜索 | `pages/genealogy/g06-search-genealogies.vue` | 已迁移;未验收 |
| `pages/genealogy/g10-application-review` | G10 管理员入谱审核 | `pages/genealogy/g10-application-review.vue` | 已迁移;未验收 |
| `pages/tree/t01-tree-overview` | T01 世系树总览 | `pages/tree/t01-tree-overview.vue` | 已迁移;未验收 |
| `pages/tree/t03-member-profile` | T03 成员档案 | `pages/tree/t03-member-profile.vue` | 已迁移;未验收 |
| `pages/family/f01-family-feed` | F01 家族动态 | `pages/family/f01-family-feed.vue` | 已迁移;未验收 |
| `pages/family/f02-publish-feed` | F02 发布动态 | `pages/family/f02-publish-feed.vue` | 已迁移;未验收 |
| `pages/skeleton/content-list-skeleton` | 多类型内容列表功能骨架 | `pages/skeleton/content-list-skeleton.vue` | 已迁移;不能计为设计页 |
| `pages/notification/n01-message-center` | N01 消息中心 | `pages/notification/n01-message-center.vue` | 已迁移;未验收 |
| `pages/profile/m01-profile-home` | M01 我的首页 | `pages/profile/m01-profile-home.vue` | 已迁移;未验收 |
迁移要求:同一轮更新页面文件、`pages.json`、所有跳转路径、测试和中文文件头注释;未完成全部引用更新时,不得删除旧路径。
## 3. 最终资产目录
```text
static/assets/
foundation/
opaque/
page-paper.jpg
auth-page-paper.jpg
auth-header.png
root-header-cinnabar.jpg
transparent/
brand-seal.png
auth-title-cloud.png
auth-divider-knot.png
auth-button-corner.png
...
modules/
genealogy/
opaque/
transparent/
tree/
opaque/
transparent/
family/
opaque/
transparent/
```
当前已迁入的资产以第 4 节台账为准。`auth-primary-button.png``auth-secondary-button.png` 尚未生成:在它们验收并替换 A01 的旧角标拼接实现前,A01 不能按“完整资产规则”关闭地基验收。
## 4. 当前资产台账
以下为当前唯一可用资产路径。尺寸为原始像素;页面中的显示尺寸仍以组件/页面样式为准。
| 路径 | 属性/尺寸 | 用途 | 引用位置 | 状态 |
| --- | --- | --- | --- | --- |
| `foundation/opaque/auth-page-paper.jpg` | opaque / 750×1334 | 认证页纸纹底色 | A01、A02 | 保留 |
| `foundation/opaque/auth-header.png` | opaque / 750×196 | 认证页红色页头 | A01、A02 | 保留 |
| `foundation/opaque/page-paper.jpg` | opaque / 852×1846 | 根页宣纸底图 | G01 | 保留 |
| `foundation/opaque/root-header-cinnabar.jpg` | opaque / 720×184 | 根页朱砂页头 | `PageHeader` | 保留 |
| `foundation/transparent/auth-ink-scenery.png` | transparent / 750×1334 | 认证页淡墨叠层 | A01、A02 | 保留 |
| `foundation/transparent/auth-title-cloud.png` | transparent / 200×120 | A01 标题左右云纹 | A01 | 保留 |
| `foundation/transparent/auth-divider-knot.png` | transparent / 160×96 | A01 文案分隔结 | A01 | 保留 |
| `foundation/transparent/auth-button-corner.png` | transparent / 160×160 | A01 旧按钮角标;待完整按钮替换 | A01 | 过渡保留 |
| `foundation/opaque/a01-primary-button-candidate.png` | opaque / 1983×793 | A01 主按钮完整图片候选;未接入 | 无 | 待设计图复核 |
| `foundation/opaque/a01-secondary-button-candidate.png` | opaque / 1983×793 | A01 次按钮完整图片候选;未接入 | 无 | 待设计图复核 |
| `foundation/transparent/auth-login-outline.png` | transparent / 96×96 | 登录图标 | A01 | 保留 |
| `foundation/transparent/auth-wechat.png` | transparent / 96×96 | 微信图标 | A01 | 保留 |
| `foundation/transparent/brand-seal.png` | transparent / 240×288 | 品牌谱印 | A01、A02、`PageHeader` | 保留 |
| `foundation/transparent/root-header-hall.png` | transparent / 750×300 | 根页祠堂线稿 | `PageHeader` | 保留 |
| `foundation/transparent/footer-mountain-bamboo.png` | transparent / 750×360 | 根页底部山水竹影 | G01 | 保留 |
| `foundation/transparent/notice.png` | transparent / 96×96 | 通知图标 | `PageHeader` | 保留 |
| `foundation/transparent/chevron-right.png` | transparent / 96×96 | 列表右箭头 | `GenealogyCard` | 保留 |
| `foundation/transparent/meta-location.png` | transparent / 96×96 | 地点元信息 | G01、`GenealogyCard` | 保留 |
| `foundation/transparent/meta-member.png` | transparent / 96×96 | 成员元信息 | G01、`GenealogyCard` | 保留 |
| `foundation/transparent/meta-admin.png` | transparent / 96×96 | 管理员元信息 | G01 | 保留 |
| `foundation/transparent/tab-genealogy.png` | transparent / 96×96 | 家谱 Tab 默认态 | `AppTabbar` | 保留 |
| `foundation/transparent/tab-genealogy-active.png` | transparent / 96×96 | 家谱 Tab 选中态 | `AppTabbar` | 保留 |
| `foundation/transparent/tab-family.png` | transparent / 96×96 | 家族 Tab 默认态 | `AppTabbar` | 保留 |
| `foundation/transparent/tab-family-active.png` | transparent / 96×96 | 家族 Tab 选中态 | `AppTabbar` | 保留 |
| `foundation/transparent/tab-profile.png` | transparent / 96×96 | 我的 Tab 默认态 | `AppTabbar` | 保留 |
| `foundation/transparent/tab-profile-active.png` | transparent / 96×96 | 我的 Tab 选中态 | `AppTabbar` | 保留 |
| `modules/genealogy/transparent/current-slip-frame.png` | transparent / 720×272 | 当前家谱题签框 | G01 | 保留 |
| `modules/genealogy/transparent/list-slip-frame.png` | transparent / 720×144 | 家谱列表题签框 | `GenealogyCard` | 保留 |
| `modules/genealogy/transparent/section-divider.png` | transparent / 720×30 | G01 分区金线 | G01 | 保留 |
| `modules/genealogy/transparent/current-seal-frame.png` | transparent / 144×208 | 当前家谱谱印框 | G01 | 保留 |
| `modules/genealogy/transparent/row-seal-frame.png` | transparent / 112×160 | 列表谱印框 | `GenealogyCard` | 保留 |
| `modules/genealogy/transparent/create-cloud.png` | transparent / 192×96 | 新建入口云纹 | G01 | 保留 |
| `modules/genealogy/transparent/add.png` | transparent / 96×96 | 新建入口加号 | G01 | 保留 |
| `modules/genealogy/transparent/shortcut-tree.png` | transparent / 96×96 | 世系图入口 | G01 | 保留 |
| `modules/genealogy/transparent/shortcut-members.png` | transparent / 96×96 | 成员入口 | G01 | 保留 |
| `modules/genealogy/transparent/shortcut-generation-poem.png` | transparent / 96×96 | 字辈诗入口 | G01 | 保留 |
| `modules/genealogy/transparent/shortcut-application.png` | transparent / 96×96 | 审核入口 | G01 | 保留 |
本轮已迁入 35 项在用资产,删除 24 项运行时零引用的旧副本。两张 A01 候选由图像生成工具制作,不是设计图裁切,当前比例约为 2.5:1,尚不符合现有按钮约 3.6:1 的显示比例,严禁直接接入或作为验收通过依据。旧版设计记录中的历史路径不再是资产依据;后续只按本台账引用和维护。
## 5. 图片属性规则
| 类型 | 文件格式与内容 | 典型用途 | 页面使用规则 |
| --- | --- | --- | --- |
| 不透明底图 | JPG 或无透明区域 PNG;包含完整底色、纹理、边框和四角 | 页面纸纹、朱砂页头、完整主/次按钮、卡片、导航 | 直接作为对应区域的完整视觉面,不再靠 CSS 补色或叠加边角 |
| 透明叠加图 | RGBA PNG;四周透明,主体边界干净 | Logo、图标、祥云、谱印、分隔纹、边框 | 明确记录叠在何种不透明底图上;不单独承担底色 |
禁止项:整页截图、设计图局部裁切、半边边框、按钮角标拼接、CSS/SVG 绘制可见装饰、同一语义的多版本长期共存。
## 6. 资产清理登记规则
每项现有资产必须登记以下字段后才可以决定保留或删除:
| 字段 | 含义 |
| --- | --- |
| 当前路径 | 盘点时的真实文件路径 |
| 语义用途 | 它服务的视觉元素,而不是模糊类别 |
| 背景属性 | `opaque``transparent` |
| 代码引用 | 页面、组件、配置或测试中的引用位置 |
| 替代资产 | 若要迁移,写入唯一的新路径;无替代则留空 |
| 决定 | `保留``迁移后删除``无引用删除` |
执行顺序固定为:扫描引用 → 填写登记 → 迁移引用 → 编译与截图验证 → 删除旧资产 → 再次扫描确认零引用。任何一步失败都停止删除。
## 7. 中文注释约定
每个页面和组件文件以中文注释写清页面编号、用途和关键依赖;模板至少区分背景、页头、主体、操作、状态五类区域。资产图层、路由跳转、非直观状态和防回归样式要解释“为什么这样写”。显而易见的单行 CSS 不写噪声注释。
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 289 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 365 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 432 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 569 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 MiB

@@ -1,62 +0,0 @@
# 真实接口核心流程 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:** 让家谱 App 的认证、当前家谱上下文、创建首代和世系浏览形成可验证的 Mock/远端双模式核心闭环。
**Architecture:** `utils/config.js` 显式决定数据模式;`utils/api.js` 是唯一远端响应解包和错误转换位置;`utils/session.js` 与新增的上下文模块持久化 token 和当前家谱 ID。页面只调用 `appApi` 并从路由读取对象 ID。
**Tech Stack:** uni-app Vue 3、HBuilderX、uni.request、PowerShell 合同测试。
## Global Constraints
- 不将测试账号、密码或 token 写入源码、测试或文档。
- 远端模式失败必须显示错误,不能自动返回 Mock 数据。
- 所有新增页面必须登记在 `pages.json` 并纳入编译审计。
---
### Task 1: API 会话与响应契约
**Files:**
- Modify: `utils/config.js`, `utils/session.js`, `utils/api.js`
- Create: `utils/genealogy-context.js`, `tests/core-flow-contract.ps1`
- [x] 写失败测试,要求存在显式模式、登录方法、`data` 解包、token 与当前家谱 ID 持久化。
- [x] 运行 PowerShell 合同测试并确认因方法缺失失败。
- [x] 实现最小 API 适配层和上下文模块。
- [x] 重跑合同测试,确认通过。
### Task 2: 认证与我的家谱
**Files:**
- Modify: `pages/auth/login.vue`, `pages/genealogy/index.vue`, `components/GenealogyCard.vue`
- Test: `tests/core-flow-contract.ps1`
- [x] 写失败测试,要求登录页调用 API、保存 token,家谱卡片和列表使用真实 ID。
- [x] 运行测试确认失败。
- [x] 实现密码登录、错误状态与列表加载状态。
- [ ] 重跑测试并在 Mock 模式下手动走通登录至列表。
### Task 3: 当前家谱与首代录入
**Files:**
- Modify: `pages.json`, `pages/genealogy/create.vue`, `pages/genealogy/detail.vue`, `pages/tree/index.vue`, `pages/member/detail.vue`
- Create: `pages/lineage/first-person.vue`
- Test: `tests/core-flow-contract.ps1`
- [x] 写失败测试,要求新页面、路由参数和 API 方法存在,且不得写死 `1001`/`3`
- [x] 运行测试确认失败。
- [x] 实现创建家谱后录入首代、按路由 ID 加载详情/树/成员。
- [x] 重跑合同测试与 HBuilderX Sass/语言诊断。
### Task 4: 加入审核与通知的上下文修复
**Files:**
- Modify: `pages/genealogy/search.vue`, `pages/genealogy/applications.vue`, `pages/notification/index.vue`, `utils/api.js`
- Test: `tests/core-flow-contract.ps1`
- [x] 写失败测试,要求申请和审核使用当前家谱 ID,通知读取操作有对应 API 方法。
- [x] 运行测试确认失败。
- [x] 实现上下文传递与明确的本地状态更新。
- [x] 重跑所有项目测试及 HBuilderX 实际样式/模块请求验证。
@@ -1,230 +0,0 @@
# G01 信息层级与谱印校正 Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 让“我的家谱”内容区按已确认的 v5 参考稿呈现谱印、元信息、快捷入口、列表和新建按钮的视觉层级。
**Architecture:** 视觉资产只放在 `static/assets/icons/`,页面和卡片组件只引用资产与渲染动态数据。`pages/genealogy/index.vue` 负责当前家谱、快捷入口和新建按钮;`components/GenealogyCard.vue` 负责每条列表的谱印、两层元信息和右侧操作列。
**Tech Stack:** uni-app Vue 3、SCSS、项目本地 RGBA PNG、PowerShell 契约审计。
## Global Constraints
- 视觉基准:`docs/design/screens/G01-我的家谱-紧凑版设计稿-v5-Tabbar安全区.png`
- Android only;继续使用 rpx、flex、小屏 `max-width: 340px` 规则,不使用 `clip-path` 或 CSS 绘图。
- 所有新增可见图标、谱印框、云纹均为项目内透明 PNG;动态姓名/数字使用 Vue 文本,不嵌死在图片中。
- 不改接口、路由、页头、背景、Tabbar 或其他页面;仅为现有展示用 `data/mock.js` 家谱条目补充 `updatedAt` 日期字段。
- 工作目录不是 Git 仓库,本轮不初始化仓库、不创建提交。
---
### Task 1: 谱印与元信息 PNG 资产
**Files:**
- Create: `static/assets/icons/common/location-v1.png`
- Create: `static/assets/icons/common/member-meta-v1.png`
- Create: `static/assets/icons/common/admin-v1.png`
- Create: `static/assets/icons/genealogy/seal-current-frame-v1.png`
- Create: `static/assets/icons/genealogy/seal-row-frame-v1.png`
- Create: `static/assets/icons/action/create-cloud-v1.png`
- Modify: `tests/g01-visual-contract.ps1`
**Interfaces:**
- Produces 96×96 RGBA metadata icons, separate transparent current/list seal-frame PNGs, and a transparent cloud ornament consumed by Tasks 23.
- Each asset corner alpha must be 0; the current seal frame must have taller visible bounds than the row seal frame.
- [x] **Step 1: Write the failing asset contract**
Add the six paths to `$requiredAssets`, then add these assertions to `tests/g01-visual-contract.ps1`:
```powershell
foreach ($asset in @('location-v1.png', 'member-meta-v1.png', 'admin-v1.png', 'seal-current-frame-v1.png', 'seal-row-frame-v1.png', 'create-cloud-v1.png')) {
if ($page -notmatch [regex]::Escape($asset) -and $card -notmatch [regex]::Escape($asset)) {
throw "G-01 does not consume $asset"
}
}
```
- [x] **Step 2: Run the contract and verify it fails**
Run: `powershell -ExecutionPolicy Bypass -File tests\g01-visual-contract.ps1`
Expected: `FAIL` because the asset files and component references do not exist.
- [x] **Step 3: Generate and inspect the assets**
Generate one asset at a time on a flat chroma-key background, remove the chroma key to alpha, and inspect the selected PNG before adding it. Use this visual language: antique-gold single/double outline, Chinese genealogy motif, no text, no shadow, transparent canvas. For the seal frames, generate only the red-and-gold frame; do not include surname or “家谱” text.
- [x] **Step 4: Verify transparent corners**
Run a PowerShell `System.Drawing.Bitmap` alpha audit that checks the four canvas corners of every new PNG are 0.
Expected: all six assets report `PASS ... transparent corners`.
### Task 2: 当前家谱题签、快捷入口与新建按钮
**Files:**
- Modify: `pages/genealogy/index.vue`
- Modify: `tests/g01-visual-contract.ps1`
**Interfaces:**
- Consumes the Task 1 metadata icons, `seal-current-frame-v1.png`, and `create-cloud-v1.png`.
- Keeps `currentGenealogy` as the data owner; does not add API calls.
- [x] **Step 1: Write the failing page-layout contract**
Require the current title block to render three `.current-meta-item` entries and the current seal to include a `.current-seal-frame` image. Require the shortcut display size and create cloud image:
```powershell
foreach ($token in @('current-meta-item', 'current-seal-frame', 'location-v1.png', 'member-meta-v1.png', 'admin-v1.png', 'create-cloud-v1.png')) {
if ($page -notmatch $token) { throw "G-01 current panel is missing $token" }
}
if ($page -notmatch 'width:\s*82rpx') { throw 'G-01 shortcut icons remain below the reference visual size.' }
```
- [x] **Step 2: Run the contract and verify it fails**
Run: `powershell -ExecutionPolicy Bypass -File tests\g01-visual-contract.ps1`
Expected: `FAIL` because current content is still a pure text metadata row and has no seal/cloud image references.
- [x] **Step 3: Implement the minimum current-panel markup**
Replace the pure red `current-seal` block with an image layer and dynamic text overlay:
```vue
<view class="current-seal">
<image class="current-seal-frame" src="/static/assets/icons/genealogy/seal-current-frame-v1.png" mode="scaleToFill" />
<text class="current-seal-title">家谱</text>
</view>
```
Render three icon/text metadata groups using the created `location-v1.png`, `member-meta-v1.png`, and `admin-v1.png`; retain `currentGenealogy.location` and `currentGenealogy.memberCount` as the text values.
- [x] **Step 4: Implement the spacing-only CSS changes**
Set shortcut image box to `82rpx`; increase label size and give the shortcut grid the reference-like vertical space. Use flex wrapping for current metadata and keep the existing `@media screen and (max-width: 340px)` behavior so no item overlaps.
- [x] **Step 5: Implement the create-button ornament**
Place two instances of `create-cloud-v1.png` inside `.create-action`; mirror only the right PNG with `transform: scaleX(-1)`. Keep the existing add PNG and navigation handler.
- [x] **Step 6: Run the contract and verify it passes**
Run: `powershell -ExecutionPolicy Bypass -File tests\g01-visual-contract.ps1`
Expected: `PASS G-01 visual contract`.
### Task 3: 家谱列表谱印与信息层级
**Files:**
- Modify: `components/GenealogyCard.vue`
- Modify: `data/mock.js`
- Modify: `tests/g01-visual-contract.ps1`
**Interfaces:**
- Consumes `genealogy` (`surname`, `name`, `location`, `memberCount`, `updatedAt`), `role`, and the Task 1 row seal asset.
- Emits the unchanged `select` event.
- [x] **Step 1: Write the failing card-layout contract**
Require `seal-row-frame-v1.png` and an explicit `.card-detail-row`:
```powershell
foreach ($token in @('seal-row-frame-v1.png', 'card-detail-row', 'card-updated')) {
if ($card -notmatch $token) { throw "G-01 list card is missing $token" }
}
if ($card -match 'background:\s*#b93b2e') { throw 'G-01 list seal is still a plain CSS red block.' }
```
- [x] **Step 2: Run the contract and verify it fails**
Run: `powershell -ExecutionPolicy Bypass -File tests\g01-visual-contract.ps1`
Expected: `FAIL` because the current list card has no frame asset or two-level detail markup.
- [x] **Step 3: Implement the minimal list-card markup**
Use a `.surname-seal` image frame with text overlay, then split metadata into title/detail/update rows:
```vue
<view class="card-detail-row">
<text class="card-meta">{{ genealogy.location }} · {{ genealogy.memberCount }} 位成员</text>
<view class="card-updated"><text>更新于 {{ genealogy.updatedAt }}</text></view>
</view>
```
On narrow screens, hide `.card-updated`; preserve the role and chevron column.
- [x] **Step 3a: Add the visual fixture dates**
Add `updatedAt: '2024-05-12'` to genealogy `1001` and `updatedAt: '2024-04-28'` to genealogy `1002` in `data/mock.js`. These fields are display-only fixtures and do not change the API contract.
- [x] **Step 4: Implement CSS sizing and small-screen fallback**
Increase card name/title prominence, keep row-frame as the only paper frame, set right-side role/chevron to a centered fixed column, and make `.card-updated` disappear at `max-width: 340px` before text can collide.
- [x] **Step 5: Run the contract and verify it passes**
Run: `powershell -ExecutionPolicy Bypass -File tests\g01-visual-contract.ps1`
Expected: `PASS G-01 visual contract`.
### Task 4: Documentation and full verification
**Files:**
- Modify: `docs/design/G01_我的家谱_设计记录.md`
- Modify: `docs/规划.md`
**Interfaces:**
- Records only completed implementation and verification; leaves G01 unchecked until an HBuilderX comparison screenshot is accepted.
- [x] **Step 1: Record the completed implementation scope**
Append the new asset paths, the explicit visual reference, and the small-screen fallback to the G01 design record. Mark the implementation subtask complete in `docs/规划.md`; do not mark the full G01 design complete.
- [x] **Step 2: Run focused verification**
Run:
```powershell
powershell -ExecutionPolicy Bypass -File tests\g01-visual-contract.ps1
powershell -ExecutionPolicy Bypass -File tests\compile-audit.ps1
powershell -ExecutionPolicy Bypass -File tests\uni-scss-injection.ps1
```
Expected: three `PASS` lines and no warnings.
- [ ] **Step 3: Capture visual evidence in HBuilderX**
At the same phone viewport as the supplied comparison, capture G01 and compare it side-by-side with the v5 reference. Confirm the current title/meta row, all three seals, four shortcut icon sizes, list row hierarchy, and cloud button ornament. If a mismatch remains, record it rather than marking G01 complete.
## Plan self-review
- Spec coverage: Tasks 13 implement every asset, layout, small-screen, and visual hierarchy requirement; Task 4 records and verifies it.
- No placeholders: all new paths, CSS tokens, component classes, commands, and expected outputs are named.
- Consistency: page uses current panel assets; card uses row seal and metadata assets; both are enforced by one G01 visual contract.
### Task 5: 参考稿字段与列表基线校正
**Files:**
- Modify: `data/mock.js`
- Modify: `pages/genealogy/index.vue`
- Modify: `components/GenealogyCard.vue`
- Modify: `tests/g01-visual-contract.ps1`
**Interfaces:**
- Produces reference-matched visual fixture fields without changing any remote API contract.
- Keeps the existing navigation and card `select` event unchanged.
- [x] **Step 1: Write the failing visual-fixture contract**
Require the four reference names/locations/counts, `.current-info-divider`, a row-direction `.card-side`, and no `calendar-v1.png` use in the list component.
- [x] **Step 2: Run the contract and verify it fails**
Run: `powershell -ExecutionPolicy Bypass -File tests\g01-visual-contract.ps1`
Expected: `FAIL` because the old demonstration fields, missing current divider, vertical role/arrow, and calendar icon are still present.
- [x] **Step 3: Apply the smallest source correction**
Replace only G01 mock fixture fields with the strings in the approved reference. Add the current-panel divider; render fixed vertical “家谱” list seal text; remove the list calendar icon; align `.card-role` and `.card-chevron` horizontally.
- [x] **Step 4: Run focused verification**
Run the G01 visual contract, PNG alpha audit, compile audit, SCSS injection audit, and Vue 3 entry audit. Keep Task 4 visual capture unchecked until the user accepts a new HBuilderX screenshot.
@@ -1,221 +0,0 @@
# G-01 Visual Shell 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:** Rebuild the Android “我的家谱” G-01 page as the approved 家祠卷轴 design with verified local PNG assets and stable mock presentation data.
**Architecture:** pages/genealogy/index.vue owns local visual states only. It composes a heritage header, paper-slip list items, and safe bottom navigation; it does not make remote API calls during this visual-construction phase.
**Tech Stack:** uni-app, Vue 3 script setup, SCSS/rpx, local RGBA PNG/JPG assets, PowerShell checks.
## Global Constraints
- Android only; verify 360 × 800 and 320 × 568 without overlap.
- No API, authentication, packaging, dependency, or unrelated-flow changes.
- Every functional icon is a local RGBA PNG under static/assets/icons/.
- Header uses the confirmed original brand/jiapu-seal-logo.png in the left safe area; title is centered; notification dot renders only for unread messages.
- Tabbar is fixed at the bottom, has 64dp content plus Android navigation inset, and keeps the icon and label clear of the lower edge.
- Do not mark G-01 design complete until assets, normal/empty/loading states, and small-screen checks have evidence.
## File Responsibilities
| File | Responsibility |
| --- | --- |
| static/assets/icons/genealogy/*-v2.png | Four compact antique-gold G-01 shortcut icons. |
| static/assets/icons/action/add-v2.png | The compact 新建家谱 icon. |
| static/assets/icons/common/chevron-right-v2.png | Compact right-arrow asset for the paper-slip list row. |
| static/assets/icons/tab/*-v4.png | Default and active 24dp tab icon pairs. |
| components/PageHeader.vue | Heritage header, original Logo, notification PNG and optional dot. |
| components/AppTabbar.vue | Fixed Android-safe three-tab navigation. |
| components/GenealogyCard.vue | Compact paper-slip genealogy list row. |
| pages/genealogy/index.vue | G-01 content, local state and route actions. |
| tests/g01-visual-contract.ps1 | Asset and static page-contract checks. |
## Task 1: Produce and register G-01 visual assets
**Files:**
- Create: static/assets/icons/genealogy/tree-v2.png
- Create: static/assets/icons/genealogy/members-v2.png
- Create: static/assets/icons/genealogy/generation-poem-v2.png
- Create: static/assets/icons/genealogy/application-v2.png
- Create: static/assets/icons/action/add-v2.png
- Create: static/assets/icons/common/chevron-right-v2.png
- Create: static/assets/icons/tab/{genealogy,family,profile}-v4.png
- Create: static/assets/icons/tab/{genealogy,family,profile}-active-v4.png
- Create: tests/g01-visual-contract.ps1
- Modify: docs/design/G01_我的家谱_设计记录.md
**Interfaces:**
- Consumes: D1 icon rules and the G-01 asset table.
- Produces: the listed asset paths; no legacy static/icons/tab-*.png consumer remains.
- [x] **Step 1: Write the failing asset contract**
$root = Split-Path -Parent $PSScriptRoot
$requiredAssets = @(
'static/assets/icons/brand/jiapu-seal-logo.png',
'static/assets/icons/common/notice-v3.png',
'static/assets/icons/genealogy/tree-v2.png',
'static/assets/icons/genealogy/members-v2.png',
'static/assets/icons/genealogy/generation-poem-v2.png',
'static/assets/icons/genealogy/application-v2.png',
'static/assets/icons/action/add-v2.png',
'static/assets/icons/common/chevron-right-v2.png',
'static/assets/icons/tab/genealogy-v4.png',
'static/assets/icons/tab/genealogy-active-v4.png',
'static/assets/icons/tab/family-v4.png',
'static/assets/icons/tab/family-active-v4.png',
'static/assets/icons/tab/profile-v4.png',
'static/assets/icons/tab/profile-active-v4.png'
)
foreach ($asset in $requiredAssets) {
if (-not (Test-Path -LiteralPath (Join-Path $root $asset))) {
throw "Missing G-01 visual asset: $asset"
}
}
Write-Output 'PASS G-01 visual contract'
- [x] **Step 2: Run the contract and confirm it fails**
Run: powershell -ExecutionPolicy Bypass -File C:\Users\Rain\Desktop\job\jiapuapp\tests\g01-visual-contract.ps1
Expected: an error that names tree-v2.png as missing.
- [x] **Step 3: Generate and validate every icon**
Generate each asset separately as a tight 96 × 96px transparent PNG. Shortcut and add icons use antique-gold #B58A4B with the approved restrained double-line style. Tab pairs use the same 24dp silhouette in default ink #342A24 and selected cinnabar #A33B2B; no text, clouds, leaves, shadows, or excess transparent padding.
- [x] **Step 4: Inspect and register accepted assets**
Inspect Tabs at 46rpx and shortcuts at 48rpx. Reject opaque corners, excess canvas padding, unrecognizable semantics, and any default/selected silhouette mismatch. Update the G-01 asset table with actual path, 96 × 96px, transparency result, and display-size result. Do not tick page-completion boxes yet.
- [x] **Step 5: Re-run the asset contract**
Expected: PASS G-01 visual contract.
## Task 2: Rebuild header and Android-safe Tabbar
**Files:**
- Modify: components/PageHeader.vue
- Modify: components/AppTabbar.vue
- Modify: tests/g01-visual-contract.ps1
**Interfaces:**
- Produces: PageHeader title=我的家谱 with unreadCount, and AppTabbar active=genealogy.
- [x] **Step 1: Extend the failing static contract**
$header = Get-Content -Raw -Encoding UTF8 (Join-Path $root 'components/PageHeader.vue')
$tabbar = Get-Content -Raw -Encoding UTF8 (Join-Path $root 'components/AppTabbar.vue')
if ($header -notmatch 'jiapu-seal-logo\.png') { throw 'Header does not use the approved brand PNG.' }
if ($header -notmatch 'notice-v3\.png') { throw 'Header does not use the approved notice PNG.' }
if ($header -notmatch 'unreadCount\s*>\s*0') { throw 'Header badge is not conditional on unread count.' }
if ($tabbar -match '/static/icons/tab-') { throw 'Tabbar still references rejected legacy tab assets.' }
if ($tabbar -notmatch 'safe-area-inset-bottom') { throw 'Tabbar has no Android safe-area padding.' }
if ($tabbar -notmatch 'font-size:\s*24rpx') { throw 'Tabbar label size is below the approved G-01 size.' }
- [x] **Step 2: Confirm the contract fails before components change**
Run the focused contract.
Expected: failure describing the legacy header or Tabbar implementation.
- [x] **Step 3: Implement the header**
Use image-only controls: an 88rpx Logo touch area, centered title, and equal-width 88rpx notice touch area using notice-v3.png. Add v-if unreadCount > 0 for the cinnabar dot. Use the header line-art PNG over a cinnabar base. Do not render text labels for Logo or notification action.
- [x] **Step 4: Implement the Tabbar**
Map every item to its v4 default/active PNG paths and choose by active === item.key. Keep a paper-white fixed bar, visible content height 128rpx, padding-bottom env(safe-area-inset-bottom), icon 48rpx, label 24rpx, and each touch target at least 96rpx. Do not use gap.
- [x] **Step 5: Re-run the contract**
Expected: PASS G-01 visual contract.
## Task 3: Rebuild G-01 content and presentation states
**Files:**
- Modify: components/GenealogyCard.vue
- Modify: pages/genealogy/index.vue
- Modify: tests/g01-visual-contract.ps1
**Interfaces:**
- Consumes: genealogies and notifications from @/data/mock.js.
- Produces: isLoading, hasGenealogies, createdGenealogies, and joinedGenealogies visual branches.
- [x] **Step 1: Add the failing local-state contract**
$page = Get-Content -Raw -Encoding UTF8 (Join-Path $root 'pages/genealogy/index.vue')
if ($page -match "from '@/utils/api\.js'") { throw 'G-01 visual phase must not call the remote API.' }
foreach ($state in @('isLoading', 'hasGenealogies', 'createdGenealogies', 'joinedGenealogies')) {
if ($page -notmatch $state) { throw "G-01 is missing presentation state: $state" }
}
foreach ($asset in @('tree-v2.png', 'members-v2.png', 'generation-poem-v2.png', 'application-v2.png', 'add-v2.png')) {
if ($page -notmatch [regex]::Escape($asset)) { throw "G-01 does not consume $asset" }
}
- [x] **Step 2: Confirm the contract fails on the legacy page**
Expected: failure for the API import or missing visual state.
- [x] **Step 3: Implement the page state owner**
Remove appApi, genealogyContext, and the mounted API call. Import genealogies and notifications from @/data/mock.js, then define:
const isLoading = ref(false)
const list = ref(genealogies)
const hasGenealogies = computed(() => list.value.length > 0)
const createdGenealogies = computed(() => list.value.filter((item, index) => index === 0))
const joinedGenealogies = computed(() => list.value.filter((item, index) => index > 0))
Retain only route navigation: tree /pages/tree/index, members /pages/genealogy/detail, generation poem /pages/content/list?type=poem, applications /pages/genealogy/applications, create /pages/genealogy/create, search /pages/genealogy/search, and notifications /pages/notification/index.
- [x] **Step 4: Implement approved compact layout**
Compose heritage header, current-genealogy paper slip, four equal shortcut entries with v2 assets, separate 我创建的 / 我加入的 groups, compact paper-slip rows, and the add button. Implement a distinct loading state and an empty paper/scroll state with 新建家谱 and 搜索并申请加入. Apply paper texture to the page and mountain/bamboo only to the lower content region. Add bottom content padding that clears the fixed Tabbar.
- [x] **Step 5: Simplify GenealogyCard.vue**
Use a seal-style surname block, title, short location/member metadata, identity label, and common/chevron-right-v2.png. Remove the legacy navy cover, motto, and active-member count. Ensure long copy uses min-width: 0, overflow: hidden, and text-overflow: ellipsis.
- [x] **Step 6: Re-run the focused contract**
Expected: PASS G-01 visual contract.
## Task 4: Verify and update the project record
**Files:**
- Modify: docs/design/G01_我的家谱_设计记录.md
- Modify: docs/规划.md
- Test: tests/g01-visual-contract.ps1
- Test: tests/compile-audit.ps1
- Test: tests/uni-scss-injection.ps1
- [x] **Step 1: Run the automated checks**
Run:
powershell -ExecutionPolicy Bypass -File C:\Users\Rain\Desktop\job\jiapuapp\tests\g01-visual-contract.ps1
powershell -ExecutionPolicy Bypass -File C:\Users\Rain\Desktop\job\jiapuapp\tests\compile-audit.ps1
powershell -ExecutionPolicy Bypass -File C:\Users\Rain\Desktop\job\jiapuapp\tests\uni-scss-injection.ps1
Expected: each command prints its PASS result.
- [ ] **Step 2: Verify at Android reference sizes**
Inspect 360 × 800 and 320 × 568. Confirm no overlap among Logo, title, and bell; notice dot only appears with unread entries; shortcut labels remain one line; list text truncates instead of overlaps; create action clears the Tabbar; all three Tab labels remain fully visible above system navigation.
- [ ] **Step 3: Update only verified design state**
In the G-01 record, tick only conditions backed by Tasks 12 and the two viewport checks. In the overall planning document, leave G-01 unchecked if even one state, asset, or viewport condition remains incomplete; otherwise record visual shell implemented and verified.
- [ ] **Step 4: Commit only when Git becomes available**
If a Git worktree exists, commit changed visual-shell files with message: feat: build G01 genealogy visual shell. If Git remains unavailable, report that no commit was created and do not initialize a repository.
## Plan Self-Review
- Task 1 covers local assets and transparency checks.
- Task 2 covers the Logo, conditional notification dot, and safe Tabbar.
- Task 3 covers G-01 normal, loading, and empty states plus approved compact layout.
- Task 4 requires automated and small-screen verification before any completion status changes.
@@ -1,123 +0,0 @@
# A-01 / A-02 本地 UI 落地执行计划
> **执行说明:** 当前实现仅覆盖 `A-01 启动登录引导` 与 `A-02 双标签登录页` 的本地 UI 和本地交互;不接后端、不写 token、不跳转家谱页、不改 Android 打包配置。
**目标:** 将已确认的 A-01 / A-02 设计落实为可运行的 uni-app 页面,并把“显式阅读并同意协议”作为进入登录页的唯一前置条件。
**实现合同:** `docs/superpowers/specs/2026-07-13-a01-a02-static-ui-implementation-design.md`
**技术边界:** Vue 3 `<script setup>`、uni-app;仅改路由入口和认证页;仅复用 `static/assets/backgrounds/auth-*``static/assets/icons/auth/*` 中的资源。
---
## 任务 1:先建立 A-01 / A-02 页面合同检查
**文件:**
- 新建:`tests/a01-a02-ui-contract.ps1`
**步骤:**
1. 编写 PowerShell 合同测试,读取 `pages.json``pages/auth/entry.vue``pages/auth/login.vue`
2. 使用 UTF-8 Base64 常量保存中文断言,避免 PowerShell 文件编码影响中文匹配。
3. 断言 `pages/auth/entry` 是第一个页面,A-01 有显式协议勾选、账号登录、微信登录、文字注册入口,A-02 有两个标签、忘记密码和本地待接入反馈。
4. 断言认证页中不再出现 `appApi``calcMD5``loginWithPassword``uni.reLaunch` 和旧的“登录即表示同意”文案。
5. 在页面改造前运行该测试,预期因 A-01 页面尚不存在而失败;不提交红灯状态。
**验证:**
```powershell
& .\tests\a01-a02-ui-contract.ps1
```
预期:失败,指出缺少 `pages/auth/entry.vue`
---
## 任务 2:创建 A-01 启动登录引导页并注册路由
**文件:**
- 修改:`pages.json`
- 新建:`pages/auth/entry.vue`
**步骤:**
1.`pages.json``pages` 数组首位加入 `pages/auth/entry`,保留现有认证页路径和所有其余路由。
2. 建立 `entry.vue`:使用祖祠红色横幅、宣纸和山水背景资源,呈现标题、品牌说明、账号密码登录、微信登录、文字注册入口和协议区域。
3.`agreed` 作为唯一的本地状态。账号登录在勾选后仅执行:
```js
uni.navigateTo({ url: '/pages/auth/login?agreed=1' })
```
4. 所有 A-01 操作在未勾选时统一提示:
```js
uni.showToast({ title: '请先阅读并同意相关协议', icon: 'none' })
```
5. 勾选后,微信登录提示“微信登录功能准备中”,注册入口提示“注册页面准备中”;协议文字本身提示“协议页面准备中”。不创建尚未设计的协议、注册或微信授权页面。
6. 用页面内最小样式匹配已验收的 A-01:窄红色顶部、宣纸主体、红色主按钮、金色描边次按钮、底部山水留白;主内容最大宽度控制为移动端阅读尺寸,并预留安全区。
**验证:**
```powershell
& .\tests\a01-a02-ui-contract.ps1
```
预期:仍失败,仅因 A-02 旧页面含 API / 旧同意逻辑。
---
## 任务 3:将现有 login 页面替换为 A-02 双标签本地 UI
**文件:**
- 修改:`pages/auth/login.vue`
**步骤:**
1. 删除 `appApi``calcMD5`、密码加密、网络请求和登录后 `reLaunch`;不保留兼容分支。
2.`onLoad` 读取 `agreed=1`,并仅以此本地参数决定是否允许提交;缺少该参数时,任何提交均提示“请先返回登录引导确认协议”。
3. 默认显示“账号密码登录”标签,字段为账号/手机号与密码;下方保留“忘记密码”文字入口,点击提示“忘记密码页面准备中”。
4. 增加“手机验证码登录”标签,字段为手机号与验证码,并提供不发请求的“获取验证码”次级按钮,点击提示“获取验证码功能待接入”。
5. 两种登录提交完成本地必填校验后均只提示“登录接口待接入”,不得调用网络、写入本地登录态或跳转业务页。
6. 延续 A-01 视觉语言:同款顶部和纸质背景、红金色标签与主按钮、输入区域清晰分组;不复写协议同意逻辑或出现“登录即表示同意”文案。
**验证:**
```powershell
& .\tests\a01-a02-ui-contract.ps1
```
预期:通过。
---
## 任务 4:做最小可编译验证并更新交接状态
**文件:**
- 修改:`docs/交接记录.md`
- 修改:`docs/规划.md`
**步骤:**
1. 运行认证页合同测试和现有 Vue 编译审计;若项目现有全部 PowerShell 测试可在合理时间内运行,则一并运行。
2. 检查变更只覆盖 A-01/A-02、路由入口、对应合同测试和状态记录;不触碰用户已有的三个脏文件。
3. 在交接记录与规划中明确记录:A-01/A-02 已完成本地 UI,所有认证接口、协议正文、真实微信授权、注册、找回密码仍未接入并等待后续页面设计/接口阶段。
4. 提交仅包含本轮 A-01/A-02 代码、测试和文档的文件。
**验证:**
```powershell
& .\tests\a01-a02-ui-contract.ps1
& .\tests\vue3-compile-audit.ps1
git diff --check
git status --short
```
预期:前两项通过、`git diff --check` 无输出,状态中只出现本轮受管文件及已知用户脏文件。
---
## 自检
- [x] 实现范围只含 A-01、A-02 和它们的入口路由。
- [x] 以实现合同为唯一行为来源,没有引入新认证合同。
- [x] 明确列出红灯、绿灯以及不提交红灯状态的规则。
- [x] 每个任务都给出精确文件和可执行验证命令。
- [x] 无 API、无 token、无伪造完成页、无 Android 配置改动。
@@ -1,260 +0,0 @@
# A-01 启动 / 登录引导设计证据执行计划
> 已失效:本计划基于“四个独立按钮”入口结构。当前方案改为“登录 + 微信登录 + 文字注册入口”,不得继续执行;待设计记录复核后另写新计划。
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to execute this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 完成 A-01 的可归档效果稿、固定栅格、状态设计、本地 PNG 资产和小屏验收;不实现任何 uni-app 页面或接口。
**Architecture:** `docs/design/A01_启动登录引导_设计记录.md` 是唯一需求所有者。整页效果稿只作为美术锚点,真实背景和图标分别存入 `static/assets/backgrounds/``static/assets/icons/`,并由 PowerShell 审计脚本验证归档、文件存在和透明 PNG 条件。
**Tech Stack:** Markdown、PNG/JPG 静态资产、PowerShell 5.1 审计、HBuilderX Web 浏览器预览。
## Global Constraints
- 只做 Android `uni-app` 的页面设计;不改 Vue 页面、接口、真实登录、打包或依赖。
- A-01 的唯一需求所有者是 `docs/design/A01_启动登录引导_设计记录.md`;本计划不可重定义页面规则。
- 账号密码、手机验证码、微信、注册账号四个入口顺序固定;协议默认不勾选,禁止“登录即同意”。
- 登录欢迎页使用独立的红头/宣纸欢迎背景,不复用 G-01 的列表页大山水背景。
- 所有功能图标必须为项目内 `96 × 96px` RGBA 透明 PNG;微信标志必须为本地官方或许可的原色透明 PNG,并记录来源。
- 每个视觉产物先归档到项目并写入设计记录,再展示或进入下一关;全页效果稿不能代替真实图标资产或小屏验收。
---
### Task 1: 建立 A-01 设计归档关口
**Files:**
- Create: `tests/a01-design-record-audit.ps1`
- Modify: `docs/design/A01_启动登录引导_设计记录.md`
- Test: `tests/a01-design-record-audit.ps1`
**Interfaces:**
- Consumes: `docs/design/A01_启动登录引导_设计记录.md``docs/design/screens/A01-启动登录引导-效果稿-v2-独立注册.png`
- Produces: Exit code `0` only when设计记录包含四个入口、显式协议规则与 v2 效果稿归档路径。
- [ ] **Step 1: 写入会失败的归档审计脚本**
```powershell
function ConvertFrom-Utf8Base64 {
param([string]$Value)
return [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($Value))
}
$recordPath = ConvertFrom-Utf8Base64 'ZG9jcy9kZXNpZ24vQTAxX+WQr+WKqOeZu+W9leW8leWvvF/orr7orqHorrDlvZUubWQ='
$effect = ConvertFrom-Utf8Base64 'ZG9jcy9kZXNpZ24vc2NyZWVucy9BMDEt5ZCv5Yqo55m75b2V5byV5a+8LeaViOaenOeovy12Mi3ni6znq4vms6jlhowucG5n'
$record = Get-Content -LiteralPath $recordPath -Raw -Encoding utf8
$rules = @(
(ConvertFrom-Utf8Base64 '6LSm5Y+35a+G56CB55m75b2V'),
(ConvertFrom-Utf8Base64 '5omL5py66aqM6K+B56CB55m75b2V'),
(ConvertFrom-Utf8Base64 '5b6u5L+h55m75b2V'),
(ConvertFrom-Utf8Base64 '5rOo5YaM6LSm5Y+3'),
(ConvertFrom-Utf8Base64 '5Yid5aeL5pyq5Yu+6YCJ')
)
foreach ($rule in $rules) {
if ($record -notmatch [regex]::Escape($rule)) {
throw "Missing A01 rule: $rule"
}
}
if (-not (Test-Path -LiteralPath $effect)) {
throw "Missing archived A01 effect: $effect"
}
Write-Output 'A01-DESIGN-RECORD-AUDIT PASS'
```
- [ ] **Step 2: 运行审计并确认因 v2 效果稿不存在而失败**
Run: `powershell -ExecutionPolicy Bypass -File tests\a01-design-record-audit.ps1`
Expected: `Missing archived A01 effect`
- [ ] **Step 3: 将 v2 效果稿路径写入设计记录的已归档美术锚点章节**
```markdown
- 效果稿 v2[`screens/A01-启动登录引导-效果稿-v2-独立注册.png`](screens/A01-启动登录引导-效果稿-v2-独立注册.png),用于确认独立“注册账号”按钮后的完整层级。
```
- [ ] **Step 4: 暂不运行通过态审计**
Run: 不运行;通过态必须在 Task 2 的图稿实际归档后执行。
### Task 2: 生成并归档 A-01 效果稿 v2
**Files:**
- Create: `docs/design/screens/A01-启动登录引导-效果稿-v2-独立注册.png`
- Modify: `docs/design/A01_启动登录引导_设计记录.md`
- Test: `tests/a01-design-record-audit.ps1`
**Interfaces:**
- Consumes: v1 美术锚点、A-01 四入口结构、D1 的独立登录欢迎背景规则。
- Produces: 仅作为美术锚点的 v2 PNG;不作为 UI 背景或图标来源。
- [ ] **Step 1: 生成竖屏 A-01 效果稿**
生成条件:朱砂祠堂红头与金色“谱”牌居中;暖宣纸主体;标题“家谱”和副句;四个纵向单行按钮,第一项朱砂实心,其余三项纸白古金描边;第四项文字为“注册账号”;底部为未勾选协议;淡墨建筑与竹影只作低对比背景,不遮挡文字。
- [ ] **Step 2: 先将生成文件保存为精确路径,再更新设计记录**
Run: `Get-Item -LiteralPath 'docs\design\screens\A01-启动登录引导-效果稿-v2-独立注册.png' | Select-Object Name,Length`
Expected: 文件存在且 `Length` 大于 `0`
- [ ] **Step 3: 运行归档审计并确认通过**
Run: `powershell -ExecutionPolicy Bypass -File tests\a01-design-record-audit.ps1`
Expected: `A01-DESIGN-RECORD-AUDIT PASS`
- [ ] **Step 4: 人工比对 v2 与 A-01 记录**
检查:四个入口顺序正确;注册按钮独立且不换行;协议未勾选;没有“登录即同意”;没有把 G-01 底部山水直接复制为登录背景。
### Task 3: 固定 Android 栅格与状态说明
**Files:**
- Modify: `docs/design/A01_启动登录引导_设计记录.md`
- Test: `tests/a01-design-record-audit.ps1`
**Interfaces:**
- Consumes: D1 的 `750rpx` 基准、`32rpx` 安全边距、正文最小 `24rpx`、主按钮最小 `88rpx`
- Produces: A-01 的四种验收尺寸布局表,以及正常态、协议未勾选提示、页面加载三种状态定义。
- [ ] **Step 1: 先在归档审计脚本中加入栅格和状态断言**
```powershell
$requiredDesignText = @('320 × 568', '360 × 640', '360 × 800', '412 × 915', '协议未勾选提示', '页面加载')
foreach ($item in $requiredDesignText) {
if ($record -notmatch [regex]::Escape($item)) {
throw "缺少 A-01 栅格或状态说明:$item"
}
}
```
- [ ] **Step 2: 运行审计并确认因新字段未写入而失败**
Run: `powershell -ExecutionPolicy Bypass -File tests\a01-design-record-audit.ps1`
Expected: `缺少 A-01 栅格或状态说明`
- [ ] **Step 3: 在设计记录新增固定栅格表和状态表**
表格必须为 `320 × 568``360 × 640``360 × 800``412 × 915` 分别写明:红头、标题区、四按钮区、协议区、可滚动区域和底部安全边距。状态表必须说明:正常态、协议未勾选提示、页面加载;微信授权的结果页只链接到 A-03,不在 A-01 重复设计。
- [ ] **Step 4: 运行审计并确认通过**
Run: `powershell -ExecutionPolicy Bypass -File tests\a01-design-record-audit.ps1`
Expected: `A01-DESIGN-RECORD-AUDIT PASS`
### Task 4: 生成并验收本地背景与功能 PNG 资产
**Files:**
- Create: `static/assets/backgrounds/auth-welcome-hall-v1.png`
- Create: `static/assets/backgrounds/auth-welcome-paper-v1.jpg`
- Create: `static/assets/backgrounds/auth-welcome-ink-side-v1.png`
- Create: `static/assets/icons/auth/account-login-v1.png`
- Create: `static/assets/icons/auth/phone-code-login-v1.png`
- Create: `static/assets/icons/auth/register-v1.png`
- Create: `static/assets/icons/brand/wechat-login-v1.png`
- Modify: `docs/design/A01_启动登录引导_设计记录.md`
- Test: `tests/a01-design-record-audit.ps1`
**Interfaces:**
- Consumes: D1 背景和 PNG 规则。
- Produces: 可由后续 A-01 页面本地引用的独立背景和图标,及其来源/Alpha 验收清单。
- [ ] **Step 1: 将资产存在、尺寸和透明角检查加入审计脚本**
```powershell
$pngAssets = @(
'static/assets/backgrounds/auth-welcome-hall-v1.png',
'static/assets/backgrounds/auth-welcome-ink-side-v1.png',
'static/assets/icons/auth/account-login-v1.png',
'static/assets/icons/auth/phone-code-login-v1.png',
'static/assets/icons/auth/register-v1.png',
'static/assets/icons/brand/wechat-login-v1.png'
)
foreach ($asset in $pngAssets) {
if (-not (Test-Path -LiteralPath $asset)) { throw "缺少 A-01 PNG$asset" }
}
if (-not (Test-Path -LiteralPath 'static/assets/backgrounds/auth-welcome-paper-v1.jpg')) {
throw '缺少 A-01 宣纸背景:auth-welcome-paper-v1.jpg'
}
```
- [ ] **Step 2: 运行审计并确认因资产不存在而失败**
Run: `powershell -ExecutionPolicy Bypass -File tests\a01-design-record-audit.ps1`
Expected: `缺少 A-01 PNG``缺少 A-01 宣纸背景`
- [ ] **Step 3: 创建独立背景与图标资产,并在设计记录登记用途和来源**
规则:自定义背景及账号/手机/注册图标单独生成;微信图标从官方或许可来源获取,不改色、不从截图裁切;账号/手机/注册图标导出 `96 × 96px` RGBA PNG,微信图标保留原色透明背景;设计记录逐项记录路径、用途、来源和透明角检查结果。
- [ ] **Step 4: 扩展审计脚本,检查四个登录入口图标为 `96 × 96px` RGBA 且四角 Alpha 为 0**
```powershell
Add-Type -AssemblyName System.Drawing
$iconPaths = @(
'static/assets/icons/auth/account-login-v1.png',
'static/assets/icons/auth/phone-code-login-v1.png',
'static/assets/icons/auth/register-v1.png',
'static/assets/icons/brand/wechat-login-v1.png'
)
foreach ($iconPath in $iconPaths) {
$bitmap = [System.Drawing.Bitmap]::FromFile((Resolve-Path -LiteralPath $iconPath))
try {
if ($bitmap.Width -ne 96 -or $bitmap.Height -ne 96) { throw "图标尺寸不符:$iconPath" }
foreach ($point in @(@(0,0), @(95,0), @(0,95), @(95,95))) {
if ($bitmap.GetPixel($point[0], $point[1]).A -ne 0) { throw "图标透明角不符:$iconPath" }
}
} finally { $bitmap.Dispose() }
}
```
- [ ] **Step 5: 运行完整审计并确认通过**
Run: `powershell -ExecutionPolicy Bypass -File tests\a01-design-record-audit.ps1`
Expected: `A01-DESIGN-RECORD-AUDIT PASS`
### Task 5: 小屏视觉验收与正式状态更新
**Files:**
- Modify: `docs/design/A01_启动登录引导_设计记录.md`
- Modify: `docs/规划.md`
- Modify: `docs/交接记录.md`
- Test: `tests/a01-design-record-audit.ps1`
**Interfaces:**
- Consumes: 已归档 v2、已验收资产、A-01 栅格和状态记录。
- Produces: A-01 的设计完成结论;下一页才可解除冻结并开始设计。
- [ ] **Step 1: 将四个预览尺寸和无底部遮挡检查加入审计脚本**
```powershell
$evidenceLabels = @('320 × 568', '360 × 640', '360 × 800', '412 × 915', '无重叠', '无裁切', '无底部遮挡')
foreach ($label in $evidenceLabels) {
if ($record -notmatch [regex]::Escape($label)) {
throw "缺少 A-01 验收证据:$label"
}
}
```
- [ ] **Step 2: 在 HBuilderX Web 浏览器中逐一预览四种尺寸,并记录截图结论**
验收条件:四个按钮均可见且文字不换行;协议行可到达、默认未选且不被系统导航遮挡;背景不压住文字;点击区不小于 `88rpx`;没有远程图标或 CSS 图形替代。
- [ ] **Step 3: 在设计记录写入截图来源、尺寸、正常/协议未勾选提示状态和结论**
记录必须明确区分 HBuilderX Web 浏览器预览与未来 Android 原生包验证;不得把后者写成已完成。
- [ ] **Step 4: 运行完整审计并确认通过**
Run: `powershell -ExecutionPolicy Bypass -File tests\a01-design-record-audit.ps1`
Expected: `A01-DESIGN-RECORD-AUDIT PASS`
- [ ] **Step 5: 更新总规划和交接记录,然后提交**
```powershell
git add -- tests/a01-design-record-audit.ps1 docs/design/A01_启动登录引导_设计记录.md docs/design/screens/A01-启动登录引导-效果稿-v2-独立注册.png static/assets/backgrounds/auth-welcome-hall-v1.png static/assets/backgrounds/auth-welcome-paper-v1.jpg static/assets/backgrounds/auth-welcome-ink-side-v1.png static/assets/icons/auth/account-login-v1.png static/assets/icons/auth/phone-code-login-v1.png static/assets/icons/auth/register-v1.png static/assets/icons/brand/wechat-login-v1.png docs/规划.md docs/交接记录.md
git commit -m "docs: complete A01 login guide design"
```
Expected: 仅 A-01 设计证据、资产与状态记录进入提交;不包含 Vue 页面、接口或构建缓存。
@@ -1,322 +0,0 @@
# A-01 启动/登录引导页 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:** 新增不接接口的 A-01 登录方式选择页,以账号密码为主入口,并要求用户主动勾选协议后才允许进入已设计完成的下一页。
**Architecture:** A-01 使用独立的 `pages/auth/entry.vue`,不改动现有 `pages/auth/login.vue` 的临时密码登录骨架,避免破坏现有核心流程合同。页面状态只包含 `consented` 和点击守卫;视觉由现有本地纸纹、朱砂红头、祠堂线描与山水资产组合,微信图标以本地透明 PNG 引入。A-02/A-03 的最终跳转仅在两页都完成设计并注册路由后接通。
**Tech Stack:** uni-app、Vue 3 `<script setup>`、SCSS、PowerShell 静态合同测试、本地 PNG/JPG 资产。
## Global Constraints
- 仅 Android 手机端视觉和本地交互;不调用 `appApi`、不接真实微信授权、短信服务或后端接口。
- A-01 只能有账号密码、手机号验证码、微信三个入口;不得出现注册、忘记密码、输入框、底部 Tab 或“登录即表示同意”。
- 协议默认未勾选;未勾选点击任一登录入口必须阻止跳转并提示;协议文字分别可点击。
- 账号密码是唯一朱砂实底主按钮;手机号验证码、微信为同层纸白古金描边按钮;微信标识不可改色。
- 只使用项目本地资产;第三方品牌 PNG 必须写入 D1 的来源、用途、路径与透明角检查记录。
- 页面视觉先于接口;在 A-02、A-03 设计与路由完成前,不得把 A-01 接到临时或错误的登录页。
## Execution Gate
本计划的视觉与协议部分可以在 A-01 实施时执行;**实际 `navigateTo` 跳转必须等 A-02、A-03 的页面设计获批、对应路由存在后再执行**。这是《规划.md》“页面全部设计后再处理接口/跨页联动”的硬约束,而不是兼容分支。
---
### Task 1: 先建立 A-01 静态合同测试
**Files:**
- Create: `tests/a01-login-guide-contract.ps1`
- Modify: none
**Interfaces:**
- Consumes: `pages.json`、未来的 `pages/auth/entry.vue``docs/design/D1_安卓视觉规范与页面壳.md`
- Produces: `tests/a01-login-guide-contract.ps1`,输出 `PASS A-01 login guide contract`
- [ ] **Step 1: 写入先失败的合同测试**
```powershell
$ErrorActionPreference = 'Stop'
$root = Split-Path -Parent $PSScriptRoot
$pages = Get-Content -Raw -Encoding UTF8 (Join-Path $root 'pages.json') | ConvertFrom-Json
$paths = @($pages.pages | ForEach-Object { $_.path })
if ($paths[0] -ne 'pages/auth/entry') { throw 'A-01 is not the application entry route.' }
if ('pages/auth/entry' -notin $paths) { throw 'Missing A-01 route.' }
$entry = Get-Content -Raw -Encoding UTF8 (Join-Path $root 'pages/auth/entry.vue')
foreach ($copy in @('账号密码登录', '手机号验证码登录', '微信登录', '我已阅读并同意《用户协议》与《隐私政策》')) {
if (-not $entry.Contains($copy)) { throw "Missing A-01 copy: $copy" }
}
foreach ($forbidden in @('登录即表示同意', '注册账号', '忘记密码', 'appApi', 'calcMD5', '<input')) {
if ($entry.Contains($forbidden)) { throw "Forbidden A-01 content: $forbidden" }
}
if ($entry -notmatch 'const consented = ref\(false\)') { throw 'A-01 consent must default to false.' }
if ($entry -notmatch 'if \(!consented\.value\)') { throw 'A-01 does not block unchecked login.' }
if ($entry -notmatch '请先阅读并同意相关协议') { throw 'A-01 has no unchecked-consent prompt.' }
foreach ($asset in @('header-cinnabar-texture-v2.jpg', 'header-hall-lineart.png', 'paper-rice-texture-v2.jpg', 'footer-mountain-bamboo.png', 'wechat-login.png')) {
if (-not $entry.Contains($asset)) { throw "A-01 does not consume required asset: $asset" }
}
$d1 = Get-Content -Raw -Encoding UTF8 (Join-Path $root 'docs/design/D1_安卓视觉规范与页面壳.md')
foreach ($token in @('wechat-login.png', 'A-01')) {
if (-not $d1.Contains($token)) { throw "D1 brand-asset record is missing: $token" }
}
Write-Output 'PASS A-01 login guide contract'
```
- [ ] **Step 2: 运行测试,确认当前因路由与页面缺失而失败**
Run: `powershell -ExecutionPolicy Bypass -File tests\a01-login-guide-contract.ps1`
Expected: FAIL with `A-01 is not the application entry route.` or missing `pages/auth/entry.vue`.
- [ ] **Step 3: 提交测试合同**
```powershell
git add tests/a01-login-guide-contract.ps1
git commit -m "test: define A01 login guide contract"
```
### Task 2: 准备并登记 A-01 本地资产
**Files:**
- Create: `static/assets/icons/brand/wechat-login.png`
- Modify: `docs/design/D1_安卓视觉规范与页面壳.md`
- Test: `tests/a01-login-guide-contract.ps1`
**Interfaces:**
- Consumes: D1 的第三方品牌资产规则与现有 `static/assets/backgrounds/header-cinnabar-texture-v2.jpg``header-hall-lineart.png``paper-rice-texture-v2.jpg``footer-mountain-bamboo.png`
- Produces: `wechat-login.png`RGBA 透明 PNG)及 D1 中唯一的品牌资产记录
- [ ] **Step 1: 取得微信官方或许可渠道的透明 PNG,并保存为 `static/assets/icons/brand/wechat-login.png`**
资产要求:图形为官方绿色微信标识;无文字、无白底、无阴影;四角透明;实际显示 `44rpx` 时清晰。不得从用户截图、整页效果图或生成图裁切。
- [ ] **Step 2: 在 D1 的第三方品牌资产记录中追加唯一一行**
使用 Step 1 实际下载的页面 URL 写入来源栏,并在同一行写明“RGBA,四角透明”。下载页没有可验证的授权或使用说明时,停止本任务,不得改用截图、生成图或未知来源图标。
- [ ] **Step 3: 运行透明角检查**
```powershell
Add-Type -AssemblyName System.Drawing
$image = [System.Drawing.Bitmap]::new('static/assets/icons/brand/wechat-login.png')
try {
if (-not [System.Drawing.Image]::IsAlphaPixelFormat($image.PixelFormat)) { throw 'WeChat icon has no alpha channel.' }
if ($image.GetPixel(0, 0).A -ne 0) { throw 'WeChat icon top-left corner is not transparent.' }
if ($image.Width -lt 48 -or $image.Height -lt 48) { throw 'WeChat icon is too small for mobile display.' }
Write-Output 'PASS WeChat login asset alpha audit'
} finally {
$image.Dispose()
}
```
Expected: `PASS WeChat login asset alpha audit`.
- [ ] **Step 4: 重跑 A-01 合同,确认它仍仅因页面尚未实现失败**
Run: `powershell -ExecutionPolicy Bypass -File tests\a01-login-guide-contract.ps1`
Expected: FAIL with missing A-01 route or page; it must not report a missing brand-asset record.
- [ ] **Step 5: 提交资产与 D1 记录**
```powershell
git add static/assets/icons/brand/wechat-login.png docs/design/D1_安卓视觉规范与页面壳.md
git commit -m "assets: add local WeChat login mark"
```
### Task 3: 实现 A-01 独立欢迎页与协议显式同意
**Files:**
- Create: `pages/auth/entry.vue`
- Modify: `pages.json`
- Test: `tests/a01-login-guide-contract.ps1`
**Interfaces:**
- Consumes: Task 2 的 `wechat-login.png`,现有朱砂红头、祠堂线描、纸纹、山水资产
- Produces: `pages/auth/entry.vue`,其状态名固定为 `consented`,点击守卫固定为 `enterLogin`
- [ ] **Step 1: 在 `pages.json` 的第一项新增 A-01 路由**
```json
{
"path": "pages/auth/entry",
"style": { "navigationStyle": "custom", "enablePullDownRefresh": false }
}
```
保留现有 `pages/auth/login` 路由与其顺序关系,不删除或重命名现有页面。
- [ ] **Step 2: 创建 `pages/auth/entry.vue` 的状态与协议守卫**
```vue
<script setup>
import { ref } from 'vue'
const consented = ref(false)
const toggleConsent = () => {
consented.value = !consented.value
}
const openAgreement = (name) => {
uni.showToast({ title: `${name}将在协议页开放`, icon: 'none' })
}
const enterLogin = (entry) => {
if (!consented.value) {
uni.showToast({ title: '请先阅读并同意相关协议', icon: 'none' })
return
}
uni.showToast({ title: `${entry}入口已确认`, icon: 'none' })
}
</script>
```
本任务阶段不得引入 `appApi``calcMD5`、真实登录、真实微信授权或不存在的 A-02/A-03 路由;跨页跳转在 Task 5 的执行门槛满足后替换。
- [ ] **Step 3: 写入页面主体,保持三种入口与显式复选框**
```vue
<template>
<view class="auth-entry-page">
<view class="auth-hero"><image class="auth-hall" src="/static/assets/backgrounds/header-hall-lineart.png" mode="aspectFit" /><image class="auth-seal" src="/static/assets/icons/brand/jiapu-seal-logo.png" mode="aspectFit" /></view>
<view class="auth-paper">
<image class="auth-side-ink" src="/static/assets/backgrounds/footer-mountain-bamboo.png" mode="aspectFit" />
<view class="auth-title"><text>家谱</text><text>为家族留存可传承的记忆</text></view>
<view class="auth-actions">
<button class="auth-button auth-button--primary" @click="enterLogin('账号密码登录')">账号密码登录</button>
<button class="auth-button" @click="enterLogin('手机号验证码登录')">手机号验证码登录</button>
<button class="auth-button" @click="enterLogin('微信登录')"><image src="/static/assets/icons/brand/wechat-login.png" mode="aspectFit" />微信登录</button>
</view>
<view class="auth-consent" @click="toggleConsent"><view class="auth-checkbox" :class="{ 'auth-checkbox--checked': consented }" /><text>我已阅读并同意</text><text class="auth-link" @click.stop="openAgreement('用户协议')">用户协议</text><text></text><text class="auth-link" @click.stop="openAgreement('隐私政策')">隐私政策</text></view>
</view>
</view>
</template>
```
- [ ] **Step 4: 完成最小 SCSS 外观**
要求:用 `background-image` 组合 `header-cinnabar-texture-v2.jpg``paper-rice-texture-v2.jpg`;红头可见祠堂线描;纸面底部可见但低对比的 `footer-mountain-bamboo.png`;按钮高度至少 `88rpx`;不使用 CSS Grid、`gap``clip-path`、滤镜或外部 URL。协议区域不得使用“登录即表示同意”文案,未选中复选框只显示古金圆圈,选中后显示朱砂对勾。
- [ ] **Step 5: 运行合同测试,确认通过**
Run: `powershell -ExecutionPolicy Bypass -File tests\a01-login-guide-contract.ps1`
Expected: `PASS A-01 login guide contract`.
- [ ] **Step 6: 做一次静态构建健康检查**
Run: `powershell -ExecutionPolicy Bypass -File tests\compile-audit.ps1`
Expected: `PASS compile audit`.
- [ ] **Step 7: 提交独立 A-01 页面**
```powershell
git add pages/auth/entry.vue pages.json tests/a01-login-guide-contract.ps1
git commit -m "feat: add A01 login guide"
```
### Task 4: 视觉验收与设计状态记录
**Files:**
- Modify: `docs/规划.md`
- Test: `tests/a01-login-guide-contract.ps1`, HBuilderX Android 小屏预览
**Interfaces:**
- Consumes: Task 3 完成的页面与合同
- Produces: D2 阶段下 A-01 的真实设计状态
- [ ] **Step 1: 在 HBuilderX 中预览 `pages/auth/entry` 的 `360 × 800dp` 画面**
检查:红头、标题、三个按钮和未勾选协议同时可见;账号密码为唯一红色主操作;宣纸与山水不压住协议;没有底部 Tab。
- [ ] **Step 2: 手动验证两种协议状态**
1. 初始未勾选,分别点击三个入口,均显示“请先阅读并同意相关协议”,且不跳转。
2. 勾选后复选框显示朱砂对勾;三个入口只显示本地确认提示,不请求网络。
- [ ] **Step 3: 仅在上述视觉和手动检查通过后更新 `docs/规划.md`**
`A-01 启动 / 登录引导页` 改为“视觉完成(已验收)”;D2 保持“进行中”,因为 A-02 至 A-06 尚未完成。
- [ ] **Step 4: 重跑最终验证**
Run:
```powershell
powershell -ExecutionPolicy Bypass -File tests\a01-login-guide-contract.ps1
powershell -ExecutionPolicy Bypass -File tests\compile-audit.ps1
powershell -ExecutionPolicy Bypass -File tests\uni-scss-injection.ps1
```
Expected: 三条命令均输出 `PASS`.
- [ ] **Step 5: 提交验收记录**
```powershell
git add docs/规划.md
git commit -m "docs: mark A01 visual review"
```
### Task 5: D2 跨页设计完成后的路由接通(执行门槛任务)
**Precondition:** A-02 与 A-03 的设计说明、页面文件和 `pages.json` 路由均已通过各自视觉验收;否则此任务不得开始。
**Files:**
- Modify: `pages/auth/entry.vue`
- Test: `tests/a01-login-guide-contract.ps1`(扩展路由断言)
**Interfaces:**
- Consumes: A-02 的已验收密码页路由;A-03 的已验收验证码/微信回跳页路由
- Produces: `entryRoutes`,键固定为 `password``phone``wechat`
- [ ] **Step 1: 扩展合同,要求所有目标路由已在 `pages.json` 注册**
```powershell
foreach ($route in @('pages/auth/password-login', 'pages/auth/phone-login')) {
if ($route -notin $paths) { throw "Missing approved auth target route: $route" }
}
```
- [ ] **Step 2: 将 Task 3 的本地确认提示替换为唯一的目标映射**
```js
const entryRoutes = {
password: '/pages/auth/password-login',
phone: '/pages/auth/phone-login',
wechat: '/pages/auth/phone-login?source=wechat'
}
const enterLogin = (entry) => {
if (!consented.value) {
uni.showToast({ title: '请先阅读并同意相关协议', icon: 'none' })
return
}
uni.navigateTo({ url: entryRoutes[entry] })
}
```
模板调用固定为 `enterLogin('password')``enterLogin('phone')``enterLogin('wechat')`。不得在 A-01 增加任何接口调用或注册入口。
- [ ] **Step 3: 运行合同和 A-02/A-03 各自的视觉合同**
Run: `powershell -ExecutionPolicy Bypass -File tests\a01-login-guide-contract.ps1`
Expected: `PASS A-01 login guide contract`.
- [ ] **Step 4: 提交跨页接通**
```powershell
git add pages/auth/entry.vue tests/a01-login-guide-contract.ps1
git commit -m "feat: connect A01 auth routes"
```
## Self-Review
- **规格覆盖:** Task 1 和 Task 3 覆盖三入口、主次层级、本地资产、无接口和显式协议;Task 4 覆盖小屏视觉与两种协议状态;Task 5 覆盖 A-02/A-03 的跳转契约且被 D2 设计完成门槛锁住。
- **占位检查:** 没有未落实的实现步骤。Task 2 的来源 URL 是执行时取得的审计事实;没有可验证来源即停止该任务,不得提交资产。
- **命名一致性:** 页面状态固定为 `consented`,守卫固定为 `enterLogin`,跨页唯一映射固定为 `entryRoutes`Task 1 与 Task 5 的测试和实现使用同一命名。
# 已失效历史记录(2026-07-13
> 本计划在未完成设计归档与验收前被错误建立,现禁止执行。唯一有效的 A-01 归档为 `docs/design/A01_启动登录引导_设计记录.md`;当前工作仍以 `docs/交接记录.md` 的 G-01 验收为准。
@@ -1,150 +0,0 @@
# A01 Reference Asset Rebuild 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:** Rebuild A01's visible red header, paper scene, title lockup, divider knots, and button frames from the approved 412 × 915 reference while preserving real DOM interactions.
**Architecture:** `pages/auth/entry.vue` remains the only interactive owner. Reference-derived raster assets own decorative visual details, while DOM owns button labels, icons, agreement state, and navigation. A small local asset-preparation command reads only the approved source screen, exports composited header/body layers and alpha PNG foreground assets, then leaves no runtime dependency on that command.
**Tech Stack:** Uni-app Vue 3, scoped SCSS, PowerShell/System.Drawing asset preparation, existing PowerShell contract and alpha audits.
## Global Constraints
- Visual truth is `docs/design/screens/A01-启动登录引导-栅格验收-412x915.png` at 412 × 915.
- Do not render the whole reference image as page content; header, body scene, title lockup, divider knot, and button frames are distinct assets.
- Preserve A01 agreement gating and `'/pages/auth/login?agreed=1'` navigation.
- Do not modify `components/GenealogyCard.vue` or `unpackage/dist/cache/.vite/deps/*`.
- New v2 frame/title/knot assets must have transparent outer corners; remove A01's unused v1 generated ornaments.
---
### Task 1: Encode the v2 reference-asset contract
**Files:**
- Modify: `tests/a01-a02-ui-contract.ps1`
- Modify: `tests/a01-asset-alpha-audit.ps1`
**Interfaces:**
- Consumes: current `pages/auth/entry.vue` and existing v1 A01 assets.
- Produces: a failing contract requiring `auth-header-reference-v2.png`, `auth-body-scene-v2.png`, `auth-brand-lockup-v2.png`, `auth-divider-knot-v2.png`, `auth-primary-frame-v2.png`, and `auth-secondary-frame-v2.png`.
- [ ] **Step 1: Write the failing test**
Add these required paths to the A01 entry contract:
```powershell
foreach ($asset in @(
'auth-header-reference-v2.png', 'auth-body-scene-v2.png',
'auth-brand-lockup-v2.png', 'auth-divider-knot-v2.png',
'auth-primary-frame-v2.png', 'auth-secondary-frame-v2.png'
)) {
Assert-Contains -Content $entry -Expected $asset -Message "Missing A01 reference asset: $asset"
}
Assert-NotContains -Content $entry -Unexpected 'auth-button-corner-v1.png' -Message 'A01 must not retain the rejected generated corner asset'
```
Replace the alpha-audit icon list with the four transparent v2 foreground assets and assert their source dimensions: brand lockup `240 × 110`, divider knot `80 × 48`, primary frame `291 × 65`, secondary frame `291 × 65`. Assert header `412 × 139` and body scene `412 × 776` exist as opaque scene layers.
- [ ] **Step 2: Run test to verify it fails**
Run: `powershell -ExecutionPolicy Bypass -File tests\a01-a02-ui-contract.ps1; powershell -ExecutionPolicy Bypass -File tests\a01-asset-alpha-audit.ps1`
Expected: FAIL because the six `v2` paths are not referenced or do not exist.
- [ ] **Step 3: Commit the contract only after Task 2 passes**
Do not commit an intentionally failing contract. It is staged with the asset and page changes in Task 3.
### Task 2: Prepare exact reference-derived v2 assets
**Files:**
- Create: `static/assets/backgrounds/auth-header-reference-v2.png`
- Create: `static/assets/backgrounds/auth-body-scene-v2.png`
- Create: `static/assets/icons/auth/auth-brand-lockup-v2.png`
- Create: `static/assets/icons/auth/auth-divider-knot-v2.png`
- Create: `static/assets/icons/auth/auth-primary-frame-v2.png`
- Create: `static/assets/icons/auth/auth-secondary-frame-v2.png`
- Delete: `static/assets/icons/auth/auth-button-corner-v1.png`
- Delete: `static/assets/icons/auth/auth-divider-knot-v1.png`
- Delete: `static/assets/icons/auth/auth-title-cloud-v1.png`
**Interfaces:**
- Consumes: `docs/design/screens/A01-启动登录引导-栅格验收-412x915.png`.
- Produces: fixed-pixel v2 assets used by `entry.vue`; no asset contains a clickable control or agreement copy.
- [ ] **Step 1: Export the visual layers from the approved source**
Use System.Drawing to read the 412 × 915 source. Crop rows `0..138` to `auth-header-reference-v2.png`. Crop rows `139..914` to `auth-body-scene-v2.png`, replacing the title, divider, button, register and agreement foreground rectangles with sampled rice-paper pixels before saving. The retained side architecture, bamboo, and lower mountain must stay in their original reference positions.
- [ ] **Step 2: Export alpha foregrounds**
Use the same source to create: a `240 × 110` brand lockup (dark title plus both gold clouds), an `80 × 48` divider knot, and two `291 × 65` button frame images. For the foregrounds, make the paper/background pixels transparent, preserve the exact colored ornament pixels, and for each frame clear its center rectangle while retaining the double rule and all four reference corner curls.
- [ ] **Step 3: Inspect and audit the assets**
Open the three frame/title/knot PNGs and confirm their outer corners are transparent and their visible artwork has no green matte. Run: `powershell -ExecutionPolicy Bypass -File tests\a01-asset-alpha-audit.ps1`.
Expected: PASS once the Task 1 audit rules are in place.
### Task 3: Replace the A01 decorative layer implementation
**Files:**
- Modify: `pages/auth/entry.vue`
- Modify: `docs/design/A01_启动登录引导_设计记录.md`
- Modify: `design-qa.md`
- Modify: `tests/a01-a02-ui-contract.ps1`
- Modify: `tests/a01-asset-alpha-audit.ps1`
**Interfaces:**
- Consumes: all six v2 assets from Task 2.
- Produces: reference-derived visuals with unchanged `toggleAgreement`, `openLogin`, `prepareWechatLogin`, `prepareRegister`, and `prepareAgreement` functions.
- [ ] **Step 1: Update page asset ownership**
Replace the generated header plus `header-seal` overlay with `auth-header-reference-v2.png`. Replace the current paper/scenery pair with the prepared body scene layer. Replace the text/cloud title group with `auth-brand-lockup-v2.png`; retain its wrapper only for layout. Keep CSS gold rules but replace each v1 divider ornament with `auth-divider-knot-v2.png`.
For both button types, remove all four `.button-corner-asset` images. Add one absolute `.entry-button__frame` image that uses the matching primary or secondary frame asset. Keep the DOM icon/text group above the frame and preserve existing click handlers.
- [ ] **Step 2: Update visual calibration values and records**
Set the lockup and divider image dimensions to their source ratios, keep the validated vertical anchors, and remove stale v1 asset statements from the A01 design record. Update `design-qa.md` to list the latest screenshot as pending visual confirmation rather than passed.
- [ ] **Step 3: Run the focused tests**
Run: `powershell -ExecutionPolicy Bypass -File tests\a01-a02-ui-contract.ps1; powershell -ExecutionPolicy Bypass -File tests\a01-asset-alpha-audit.ps1; powershell -ExecutionPolicy Bypass -File tests\a01-design-record-audit.ps1; powershell -ExecutionPolicy Bypass -File tests\compile-audit.ps1`
Expected: all four scripts print `PASS` with exit code 0.
- [ ] **Step 4: Run full regression verification**
Run: `Get-ChildItem -LiteralPath tests -Filter *.ps1 | Sort-Object Name | ForEach-Object { & powershell -ExecutionPolicy Bypass -File $_.FullName; if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } }; git diff --check`
Expected: every test script passes and `git diff --check` reports no errors.
- [ ] **Step 5: Commit the reference rebuild**
```powershell
git add -- pages/auth/entry.vue tests/a01-a02-ui-contract.ps1 tests/a01-asset-alpha-audit.ps1 docs/design/A01_启动登录引导_设计记录.md design-qa.md static/assets/backgrounds/auth-header-reference-v2.png static/assets/backgrounds/auth-body-scene-v2.png static/assets/icons/auth/auth-brand-lockup-v2.png static/assets/icons/auth/auth-divider-knot-v2.png static/assets/icons/auth/auth-primary-frame-v2.png static/assets/icons/auth/auth-secondary-frame-v2.png static/assets/icons/auth/auth-button-corner-v1.png static/assets/icons/auth/auth-divider-knot-v1.png static/assets/icons/auth/auth-title-cloud-v1.png
git commit -m "fix: rebuild A01 reference visual assets"
```
### Task 4: Obtain runtime evidence
**Files:**
- Modify: `design-qa.md` after receiving user-provided HBuilderX/Android screenshots.
**Interfaces:**
- Consumes: rendered unchecked A01 screenshots at 412 × 915 and 320 × 568.
- Produces: a visual QA verdict grounded in those screenshots.
- [ ] **Step 1: Compare the two same-state screenshots**
At 412 × 915, verify: header bottom gold band and seal, side architecture/bamboo/lower mountain placements, brand lockup scale, both frame corner curls, divider knots, and agreement baseline.
- [ ] **Step 2: Check narrow-screen safety**
At 320 × 568, scroll to the agreement row and verify that the checkbox and all copy remain visible above the system navigation area.
- [ ] **Step 3: Record evidence status**
Set `design-qa.md` to `final result: passed` only if both screenshots satisfy the checks; otherwise document the remaining pixel difference and keep it pending.
@@ -1,273 +0,0 @@
# A-01 Runtime Visual Calibration Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Make the A-01 unchecked entry page's real DOM match the approved 412 x 915 visual reference for its header, title, dividers, buttons, registration entry, and agreement row.
**Architecture:** `docs/design/A01_启动登录引导_设计记录.md` owns the calibrated A-01 layout contract. `pages/auth/entry.vue` consumes that contract through local scoped styles and named A-01 elements; it retains the existing local interaction handlers. `tests/a01-a02-ui-contract.ps1` guards the layout anchors and blocks the rejected registration side lines and button-corner construction.
**Tech Stack:** uni-app Vue 3, scoped SCSS, local PNG assets, PowerShell contract scripts.
## Global Constraints
- Use `docs/design/screens/A01-启动登录引导-栅格验收-412x915.png` as the sole visual reference for the unchecked state.
- Keep real DOM, local-only interaction, the existing route to A-02, and explicit agreement gating.
- Do not use the whole reference image as page content, call APIs, modify A-02, or alter the existing authentication backgrounds in this task.
- Preserve vertical scrolling and `env(safe-area-inset-bottom)` for 320 x 568.
- Use local transparent PNG assets for title cloud imagery; do not use text glyphs or emoji as ornaments.
---
### Task 1: Make the A-01 record the sole measured-layout owner and add a red visual contract
**Files:**
- Modify: `docs/design/A01_启动登录引导_设计记录.md:30-33`
- Modify: `docs/superpowers/specs/2026-07-13-a01-runtime-visual-calibration-design.md:7-31`
- Modify: `docs/superpowers/specs/2026-07-13-a01-a02-static-ui-implementation-design.md:19`
- Modify: `tests/a01-a02-ui-contract.ps1:74-87`
**Interfaces:**
- Consumes: approved `A01-启动登录引导-栅格验收-412x915.png` and the current local route/interaction contract.
- Produces: one permanent 750rpx coordinate table and a contract that rejects the old visual construction.
- [ ] **Step 1: Replace the old prose layout values in the A-01 design record with the calibrated table**
Replace the current fixed-layout bullets with this table. It is the only owner of these values:
```markdown
| 元素 | 412 × 915 像素基准 | 750rpx 实现基准 |
| --- | --- | --- |
| 朱砂家祠头图 | 高 139px | 高 253rpx |
| 标题区起点 | 红头后约 82px | `padding-top: 150rpx` |
| 标题分隔饰线 | 宽约 234px | 宽 426rpx;上、下间距独立 |
| 两个按钮 | x=60px,宽约 291px,高约 65px | `margin: 95rpx 110rpx 0`,高 118rpx |
| 两按钮间距 | 约 28px | `margin-top: 51rpx` |
| 注册入口 | 微信按钮后约 56px;无左右短线 | `margin-top: 98rpx` |
| 协议行 | 注册入口后约 70px | `margin-top: 128rpx` 加安全区 |
```
- [ ] **Step 2: Remove the superseded dimensions from the static UI implementation contract**
In `2026-07-13-a01-runtime-visual-calibration-design.md`, replace its numeric coordinate table with this ownership statement so the design record remains the only permanent coordinate source:
```markdown
## Contract ownership
`docs/design/A01_启动登录引导_设计记录.md` owns the calibrated 412 × 915 coordinate table and acceptance thresholds. This implementation specification records scope and component strategy only; it does not define duplicate layout values.
```
Then replace the static UI implementation contract's layout sentence with:
```markdown
- A-01 的运行时布局唯一遵循 `docs/design/A01_启动登录引导_设计记录.md` 中的“412 × 915 运行时校准表”;本文件不再重复或定义按钮与间距数值。
```
- [ ] **Step 3: Extend the PowerShell contract before changing the page**
Add these assertions immediately after the existing A-01 DOM-structure assertions:
```powershell
foreach ($requiredVisualAnchor in @(
'brand-divider--top',
'brand-divider--bottom',
'entry-button--primary',
'entry-button--secondary',
'height: 253rpx',
'padding: 150rpx 56rpx 0',
'width: 426rpx',
'margin: 95rpx 110rpx 0',
'height: 118rpx',
'margin-top: 51rpx',
'margin-top: 98rpx',
'margin-top: 128rpx'
)) {
Assert-Contains -Content $entry -Expected $requiredVisualAnchor -Message "Missing A-01 calibrated visual anchor: $requiredVisualAnchor"
}
foreach ($rejectedVisualConstruction in @('link-line', 'button-corner', 'class="auth-button primary-button"')) {
Assert-NotContains -Content $entry -Unexpected $rejectedVisualConstruction -Message "A-01 retains rejected visual construction: $rejectedVisualConstruction"
}
```
- [ ] **Step 4: Run the focused contract to prove it is red**
Run: `powershell -ExecutionPolicy Bypass -File tests\a01-a02-ui-contract.ps1`
Expected: failure mentioning `Missing A-01 calibrated visual anchor` or `A-01 retains rejected visual construction`.
### Task 2: Rebuild A-01's measured DOM layout without changing its interaction contract
**Files:**
- Modify: `pages/auth/entry.vue:11-65,145-324`
- Test: `tests/a01-a02-ui-contract.ps1`
**Interfaces:**
- Consumes: the measured A-01 table from the design record and existing `openLogin`, `prepareWechatLogin`, `prepareRegister`, and `toggleAgreement` handlers.
- Produces: `brand-divider--top`, `brand-divider--bottom`, `entry-button--primary`, and `entry-button--secondary` rendered from real DOM.
- [ ] **Step 1: Replace the entry markup that creates the rejected decorations**
Keep the existing cloud `image` source, but split the dividers by purpose, remove the registration side lines, remove the four corner views, and use A-01-specific button classes:
```vue
<view class="brand-divider brand-divider--top">
<view class="brand-divider__line" />
<image class="brand-divider__ornament" src="/static/assets/icons/action/create-cloud-v2.png" mode="aspectFit" />
<view class="brand-divider__line" />
</view>
<view class="entry-actions">
<view class="entry-button entry-button--primary" hover-class="entry-button--pressed" @click="openLogin">
<image class="entry-button__icon" src="/static/assets/icons/auth/login-outline-v1.png" mode="aspectFit" />
<text>登录</text>
</view>
<view class="entry-button entry-button--secondary" hover-class="entry-button--pressed" @click="prepareWechatLogin">
<image class="entry-button__icon entry-button__icon--wechat" src="/static/assets/icons/auth/wechat-licensed-v1.png" mode="aspectFit" />
<text>微信登录</text>
</view>
<view class="register-link" hover-class="link-hover" @click="prepareRegister">
<text>还没有账号?<text class="register-emphasis">注册账号</text></text>
</view>
</view>
```
Render the second divider after `brand-subtitle` with the same child structure and class `brand-divider--bottom`.
- [ ] **Step 2: Replace the old generic layout selectors with these calibrated scoped rules**
Keep the existing background and script blocks unchanged. Replace `.auth-header` through `.link-hover` with the following A-01 rules:
```scss
.auth-header { position: relative; height: 253rpx; overflow: hidden; }
.brand-intro {
display: flex;
flex-direction: column;
align-items: center;
padding: 150rpx 56rpx 0;
color: #3f2c1d;
}
.brand-divider {
display: flex;
align-items: center;
justify-content: center;
width: 426rpx;
}
.brand-divider--top { margin: 47rpx 0 21rpx; }
.brand-divider--bottom { margin: 34rpx 0 30rpx; }
.brand-divider__line { flex: 1; height: 1rpx; background: #d5a34f; }
.brand-divider__ornament { width: 44rpx; height: 24rpx; margin: 0 14rpx; }
.entry-actions { margin: 95rpx 110rpx 0; }
.entry-button {
position: relative;
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
width: 100%;
height: 118rpx;
border: 2rpx solid #d8ad63;
border-radius: 6rpx;
font-family: STKaiti, KaiTi, serif;
font-size: 38rpx;
letter-spacing: 4rpx;
}
.entry-button::after {
position: absolute;
inset: 10rpx;
border: 1rpx solid rgba(225, 180, 105, 0.92);
border-radius: 2rpx;
content: '';
pointer-events: none;
}
.entry-button--primary {
border-color: #9e170e;
background: #b7170d;
color: #fffaf0;
}
.entry-button--primary::after { border-color: #edc67c; }
.entry-button--secondary {
margin-top: 51rpx;
background: rgba(255, 252, 245, 0.82);
color: #60442f;
}
.entry-button__icon { width: 46rpx; height: 46rpx; margin-right: 18rpx; }
.entry-button__icon--wechat { width: 52rpx; }
.entry-button--primary .entry-button__icon { filter: brightness(0) invert(1); }
.entry-button > text { position: relative; z-index: 1; }
.entry-button--pressed { opacity: 0.84; }
.register-link {
display: flex;
align-items: center;
justify-content: center;
margin-top: 98rpx;
color: #60442f;
font-family: STKaiti, KaiTi, serif;
font-size: 30rpx;
letter-spacing: 2rpx;
white-space: nowrap;
}
.agreement-row { margin-top: 128rpx; }
```
Delete `.gold-divider`, `.divider-line`, `.divider-ornament`, `.auth-button`, `.primary-button`, `.secondary-button`, `.button-icon`, `.wechat-icon`, `.button-hover`, `.button-corner`, all `.corner-*` selectors, and `.link-line`. Do not change any handler or copy.
- [ ] **Step 3: Run the focused contract and relevant compilation audits**
Run:
```powershell
powershell -ExecutionPolicy Bypass -File tests\a01-a02-ui-contract.ps1
powershell -ExecutionPolicy Bypass -File tests\vue3-entry.ps1
powershell -ExecutionPolicy Bypass -File tests\compile-audit.ps1
```
Expected: all three scripts print `PASS`; the focused contract prints `A01-A02-UI-CONTRACT PASS`.
### Task 3: Record the completed static change and request runtime evidence
**Files:**
- Modify: `docs/design/A01_启动登录引导_设计记录.md:51-57`
- Modify: `design-qa.md:1-31`
- Test: `tests/a01-a02-ui-contract.ps1`, `tests/vue3-entry.ps1`, `tests/compile-audit.ps1`
**Interfaces:**
- Consumes: passing static contract and a page that still needs HBuilderX/Android visual evidence.
- Produces: an honest handoff that separates static verification from runtime screenshot acceptance.
- [ ] **Step 1: Update the design record's verification state**
Add this sentence under the runtime-verification gap:
```markdown
- 2026-07-13 已按 412 × 915 参考图重新校准真实 DOM 的标题区、分隔饰线、双按钮、注册入口与协议行;静态合同通过,但仍须由同尺寸 HBuilderX 或 Android 截图叠图确认,未确认前不得标记 A-01 完成。
```
- [ ] **Step 2: Replace the stale QA finding with the exact next capture**
Set `design-qa.md` to state that the source remains `A01-启动登录引导-栅格验收-412x915.png`, static calibration has passed, and the sole remaining evidence is an unchecked `412 x 915` HBuilderX/Android capture without browser chrome plus a `320 x 568` safety/scroll capture.
- [ ] **Step 3: Run the final static verification and inspect the scoped diff**
Run:
```powershell
powershell -ExecutionPolicy Bypass -File tests\a01-a02-ui-contract.ps1
powershell -ExecutionPolicy Bypass -File tests\vue3-entry.ps1
powershell -ExecutionPolicy Bypass -File tests\compile-audit.ps1
git diff --check
git diff -- pages/auth/entry.vue tests/a01-a02-ui-contract.ps1 docs/design/A01_启动登录引导_设计记录.md docs/superpowers/specs/2026-07-13-a01-runtime-visual-calibration-design.md docs/superpowers/specs/2026-07-13-a01-a02-static-ui-implementation-design.md design-qa.md
```
Expected: all scripts pass, `git diff --check` is silent, and no interaction, route, or API code is changed.
- [ ] **Step 4: Commit only the A-01 calibration work**
```powershell
git add -- pages/auth/entry.vue tests/a01-a02-ui-contract.ps1 docs/design/A01_启动登录引导_设计记录.md docs/superpowers/specs/2026-07-13-a01-runtime-visual-calibration-design.md docs/superpowers/specs/2026-07-13-a01-a02-static-ui-implementation-design.md design-qa.md
git commit -m "fix: calibrate A01 entry visual layout"
```
Expected: the commit includes only the listed A-01 files; it must not include `components/GenealogyCard.vue` or Vite cache files.
@@ -1,312 +0,0 @@
# A-01 Text Registration Entry Design Evidence 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:** 完成 A-01“登录 + 微信登录 + 文字注册入口”的可归档设计证据与 Android 固定栅格验收口径,不修改 uni-app 页面、接口或打包。
**Architecture:** `docs/design/A01_启动登录引导_设计记录.md` 是 A-01 当前规则的唯一所有者。PowerShell 审计脚本只验证该记录、归档效果稿和项目内静态资产;四个尺寸的效果稿是静态设计验证,不冒充 uni-app 运行时验收。
**Tech Stack:** Markdown、PNG/JPG 静态资产、PowerShell 5.1、内置图像生成工具、HBuilderX Web 浏览器(仅在页面实现阶段用于运行时验收)。
## Global Constraints
- 只做 `D2 / A-01` 的设计证据;不改 `.vue`、路由、接口、真实登录、Android 打包或依赖。
- 当前设计唯一所有者是 `docs/design/A01_启动登录引导_设计记录.md`;本计划和所有审计脚本不得重复定义入口规则。
- A-01 只有朱砂红“登录”主按钮、古金描边“微信登录”按钮和“还没有账号?注册账号”文字入口。
- 登录进入 A-02;A-02 承接“账号密码登录 / 手机验证码登录”双标签;微信授权属于 A-03,注册属于 A-04,忘记密码属于 A-05。
- 协议复选框默认未勾选。任何进入操作都不能默认视为用户同意协议;未勾选仅提示用户先确认协议。
- 使用 Android `rpx + flex` 设计口径,不使用 CSS Grid 或 `gap`;底部安全区规则必须使用 `env(safe-area-inset-bottom)`
- 登录欢迎页的头图、宣纸和山水必须是 A-01 独立资产,不复用 G-01 的 `footer-mountain-bamboo.png`
- 功能图标必须为项目内 `96 × 96px` RGBA 透明 PNG;微信标志只使用官方或许可来源的原色透明 PNG,不重绘或改色。
- 效果稿 v1、v2 是历史证据,保留但不可作为当前入口结构依据;当前整页效果稿 v3 也不能充当真实背景或图标资产。
## File Structure
| 文件 | 职责 |
| --- | --- |
| `docs/design/A01_启动登录引导_设计记录.md` | A-01 当前入口、栅格、状态、资产清单与静态验收结论的唯一设计来源。 |
| `tests/a01-design-record-audit.ps1` | 验证当前入口、路由归属、栅格约束和 v3 效果稿的归档存在。 |
| `static/assets/backgrounds/auth-ancestral-header-v1.png` | A-01 独立朱砂家祠头图,`750 × 196px`。 |
| `static/assets/backgrounds/auth-rice-paper-v1.jpg` | A-01 独立暖宣纸纹理,`750 × 1334px`。 |
| `static/assets/backgrounds/auth-ink-scenery-v1.png` | A-01 独立淡墨建筑、竹影与山水叠层,`750 × 1334px`。 |
| `static/assets/icons/auth/login-outline-v1.png` | 登录按钮使用的 `96 × 96px` 透明 PNG。 |
| `static/assets/icons/auth/wechat-licensed-v1.png` | 微信按钮使用的 `96 × 96px` 许可来源、原色透明 PNG。 |
| `tests/a01-asset-alpha-audit.ps1` | 验证 A-01 图标的尺寸、透明角和可见像素,以及 A-01 独立背景文件。 |
| `docs/design/screens/A01-启动登录引导-栅格验收-320x568.png` | 320 × 568 静态设计效果稿。 |
| `docs/design/screens/A01-启动登录引导-栅格验收-360x640.png` | 360 × 640 静态设计效果稿。 |
| `docs/design/screens/A01-启动登录引导-栅格验收-360x800.png` | 360 × 800 静态设计效果稿。 |
| `docs/design/screens/A01-启动登录引导-栅格验收-412x915.png` | 412 × 915 静态设计效果稿。 |
---
### Task 1: 锁定当前入口与固定栅格的审计关口
**Files:**
- Modify: `tests/a01-design-record-audit.ps1`
- Test: `tests/a01-design-record-audit.ps1`
**Interfaces:**
- Consumes: UTF-8 Base64 文本解码函数 `ConvertFrom-Utf8Base64`、A-01 设计记录和效果稿 v3。
- Produces: 退出码 `0``A01-DESIGN-RECORD-AUDIT PASS`;缺少任何规则或效果稿时抛出明确错误。
- [x] **Step 1: 写入会失败的栅格规则断言**
在现有 `$rules` 数组后增加以下代码;这些规则在本任务开始时尚未写入设计记录,因此审计应失败。
```powershell
$layoutRules = @(
(ConvertFrom-Utf8Base64 '5YaF5a655a695bqmIDYzOHJweA=='),
(ConvertFrom-Utf8Base64 '5Li75oyJ6ZKu6auY5bqmIDEwOHJweA=='),
(ConvertFrom-Utf8Base64 'ZW52KHNhZmUtYXJlYS1pbnNldC1ib3R0b20p'),
(ConvertFrom-Utf8Base64 '6LSm5Y+35a+G56CB55m75b2VIC8g5omL5py66aqM6K+B56CB55m75b2V')
)
foreach ($rule in $layoutRules) {
if ($record -notmatch [regex]::Escape($rule)) {
throw "Missing A01 layout rule: $rule"
}
}
$currentStructure = [regex]::Match($record, '(?s)## 2\\..*?(?=## 3\\.)').Value
$currentStructureRule = ConvertFrom-Utf8Base64 '5Lik5Liq5aSn5oyJ6ZKu5ZKM5LiA5Liq5paH5a2X5YWl5Y+j'
if ($currentStructure -notmatch [regex]::Escape($currentStructureRule)) {
throw "Missing current A01 structure rule: $currentStructureRule"
}
```
- [x] **Step 2: 运行审计并确认它先失败**
Run:
```powershell
powershell -NoProfile -ExecutionPolicy Bypass -File tests/a01-design-record-audit.ps1
```
Expected: `Missing A01 layout rule: 内容宽度 638rpx`。
- [x] **Step 3: 保持红灯测试未提交并进入 Task 2**
红灯只用于证明固定栅格规则确实缺失。不要单独提交失败测试;在 Task 2 将设计记录补齐并转绿后,再与设计记录一起提交。
### Task 2: 写入 A-01 固定栅格与状态边界
**Files:**
- Modify: `docs/design/A01_启动登录引导_设计记录.md`
- Test: `tests/a01-design-record-audit.ps1`
**Interfaces:**
- Consumes: Task 1 的 `$layoutRules` 与当前入口结构断言。
- Produces: 以 750rpx 设计画布表达的固定栅格、四类进入状态和明确的非运行时验收边界。
- [x] **Step 1: 在第 3 节加入固定栅格**
在“固定布局与状态边界”中加入以下内容,保持现有的入口与协议规则不变:
```markdown
- 固定设计画布为 `750rpx` 宽;内容宽度 638rpx,左右各 56rpx。朱砂家祠头图高 196rpx;标题与副标题区域从头图下方开始,操作区首按钮顶边固定为 646rpx。
- 主按钮高度 108rpx,微信按钮高度 108rpx,二者间距 30rpx;注册文字在微信按钮下方 56rpx,字号 28rpx,整行居中且不得换行。两个按钮文字字号 36rpx,均单行居中。
- 协议行使用 26rpx 字号,页面最小底部内边距为 `calc(32rpx + env(safe-area-inset-bottom))`。320 × 568 时页面允许纵向滚动,协议行与任一入口均不得落入系统导航区。
- 仅作静态设计验证,不代表 uni-app 运行时验收;页面实现后仍须在 Android 320 × 568、360 × 640、360 × 800、412 × 915 上重新验收。
```
- [x] **Step 2: 在第 3 节加入状态表**
紧接固定栅格条目,加入以下表格:
```markdown
| 状态 | 触发 | A-01 可见结果 | 去向 |
| --- | --- | --- | --- |
| 初始未勾选 | 首次进入 | 两个按钮、注册文字与空复选框 | 留在 A-01 |
| 未同意协议 | 点击登录、微信登录或注册账号 | 提示“请先阅读并同意相关协议”,不跳转、不改变勾选状态 | 留在 A-01 |
| 登录已确认协议 | 点击“登录” | 结束 A-01 引导 | A-02,默认账号密码登录标签 |
| 微信已确认协议 | 点击“微信登录” | 结束 A-01 引导 | A-03,显示授权中、取消或失败回跳 |
| 注册已确认协议 | 点击“注册账号”文字 | 结束 A-01 引导 | A-04 |
```
- [x] **Step 3: 运行审计并确认通过**
Run:
```powershell
powershell -NoProfile -ExecutionPolicy Bypass -File tests/a01-design-record-audit.ps1
```
Expected: `A01-DESIGN-RECORD-AUDIT PASS`。
- [x] **Step 4: 检查文档差异并提交**
```powershell
git diff --check
git add docs/design/A01_启动登录引导_设计记录.md tests/a01-design-record-audit.ps1
git commit -m "docs: define A01 grid and entry states"
```
### Task 3: 归档 A-01 独立背景和透明图标
**Files:**
- Create: `static/assets/backgrounds/auth-ancestral-header-v1.png`
- Create: `static/assets/backgrounds/auth-rice-paper-v1.jpg`
- Create: `static/assets/backgrounds/auth-ink-scenery-v1.png`
- Create: `static/assets/icons/auth/login-outline-v1.png`
- Create: `static/assets/icons/auth/wechat-licensed-v1.png`
- Create: `tests/a01-asset-alpha-audit.ps1`
- Modify: `docs/design/A01_启动登录引导_设计记录.md`
- Test: `tests/a01-asset-alpha-audit.ps1`
**Interfaces:**
- Consumes: Task 2 的 A-01 独立美术方向和项目现有 `tests/g01-asset-alpha-audit.ps1` 的透明角审计方式。
- Produces: 可由页面实现消费的独立背景与两个 `96 × 96px` RGBA 透明图标;设计记录中带来源、用途、路径、尺寸和 Alpha 验收结论的资产清单。
- [x] **Step 1: 创建先失败的资产审计脚本**
创建 `tests/a01-asset-alpha-audit.ps1`,内容如下:
```powershell
$ErrorActionPreference = 'Stop'
$root = Split-Path -Parent $PSScriptRoot
Add-Type -AssemblyName System.Drawing
$backgrounds = @(
'static/assets/backgrounds/auth-ancestral-header-v1.png',
'static/assets/backgrounds/auth-rice-paper-v1.jpg',
'static/assets/backgrounds/auth-ink-scenery-v1.png'
)
$icons = @(
'static/assets/icons/auth/login-outline-v1.png',
'static/assets/icons/auth/wechat-official-v1.png'
)
foreach ($asset in $backgrounds + $icons) {
if (-not (Test-Path -LiteralPath (Join-Path $root $asset))) {
throw "Missing A01 asset: $asset"
}
}
foreach ($asset in $icons) {
$bitmap = [System.Drawing.Bitmap]::FromFile((Join-Path $root $asset))
$width = [int]$bitmap.Width
$height = [int]$bitmap.Height
$corners = @(
$bitmap.GetPixel(0, 0).A,
$bitmap.GetPixel(($width - 1), 0).A,
$bitmap.GetPixel(0, ($height - 1)).A,
$bitmap.GetPixel(($width - 1), ($height - 1)).A
)
$visiblePixels = 0
for ($y = 0; $y -lt $height; $y++) {
for ($x = 0; $x -lt $width; $x++) {
if ($bitmap.GetPixel($x, $y).A -gt 24) { $visiblePixels++ }
}
}
$bitmap.Dispose()
if ($width -ne 96 -or $height -ne 96) { throw "$asset must be 96 x 96px." }
if (($corners | Where-Object { $_ -ne 0 }).Count -ne 0) { throw "$asset has opaque outer corners." }
if ($visiblePixels -lt 80) { throw "$asset has no usable visible artwork." }
Write-Output "PASS $asset transparent corners and visible artwork"
}
Write-Output 'A01-ASSET-ALPHA-AUDIT PASS'
```
- [x] **Step 2: 运行资产审计并确认它先失败**
Run:
```powershell
powershell -NoProfile -ExecutionPolicy Bypass -File tests/a01-asset-alpha-audit.ps1
```
Expected: `Missing A01 asset: static/assets/backgrounds/auth-ancestral-header-v1.png`。
- [x] **Step 3: 生成或下载并记录资产**
生成以下三张独立背景,不能裁切或复用 G-01 背景:朱砂家祠头图 `750 × 196px`、暖宣纸纹理 `750 × 1334px`、淡墨建筑竹影山水叠层 `750 × 1334px`。将它们存到本任务列出的精确路径。
为登录按钮准备古金或白色线性人物图标,保存为 `login-outline-v1.png`;从官方或明确许可来源取得未经改色的微信图标,保存为 `wechat-licensed-v1.png`。两个文件均为 `96 × 96px` RGBA PNG,四个角完全透明。
在 A-01 设计记录新增“资产清单”表格,并写入以下列:资产路径、用途、来源或生成提示、尺寸、透明验收。微信条目必须填入直接来源页面与获取日期;其余四项必须写入生成提示或公开许可证来源。
- [x] **Step 4: 运行资产审计并确认通过**
Run:
```powershell
powershell -NoProfile -ExecutionPolicy Bypass -File tests/a01-asset-alpha-audit.ps1
```
Expected: 每个图标输出一行 `PASS`,最后输出 `A01-ASSET-ALPHA-AUDIT PASS`。
- [x] **Step 5: 提交可消费资产与审计**
```powershell
git add static/assets/backgrounds/auth-ancestral-header-v1.png static/assets/backgrounds/auth-rice-paper-v1.jpg static/assets/backgrounds/auth-ink-scenery-v1.png static/assets/icons/auth/login-outline-v1.png static/assets/icons/auth/wechat-licensed-v1.png tests/a01-asset-alpha-audit.ps1 docs/design/A01_启动登录引导_设计记录.md
git commit -m "assets: add A01 login design resources"
```
### Task 4: 归档四个 Android 尺寸的静态设计验收稿
**Files:**
- Create: `docs/design/screens/A01-启动登录引导-栅格验收-320x568.png`
- Create: `docs/design/screens/A01-启动登录引导-栅格验收-360x640.png`
- Create: `docs/design/screens/A01-启动登录引导-栅格验收-360x800.png`
- Create: `docs/design/screens/A01-启动登录引导-栅格验收-412x915.png`
- Modify: `docs/design/A01_启动登录引导_设计记录.md`
- Modify: `docs/规划.md`
- Modify: `docs/交接记录.md`
- Test: `tests/a01-design-record-audit.ps1`
**Interfaces:**
- Consumes: Task 2 的 750rpx 栅格、Task 3 的独立资产和当前 v3 的视觉方向。
- Produces: 四张按目标尺寸生成的静态设计验收稿;设计记录明确区分静态验收与页面实现后的运行时验收。
- [x] **Step 1: 生成四张静态验收稿**
使用当前 v3 作为样式参考,为每个精确尺寸单独生成整页效果稿,不裁切同一张长图。每一张都必须显示:朱砂家祠头图、标题与副标题、两个大按钮、单行文字注册入口、未勾选协议和安全区留白。
验收时逐项检查:320 × 568 的“还没有账号?注册账号”不换行;四个尺寸的两个按钮文字均单行;协议行完整可见;底部内容不进入 `env(safe-area-inset-bottom)` 预留区;没有账号密码或手机验证码的独立大按钮。
- [x] **Step 2: 在设计记录写入静态验收结论**
在第 4 节增加四个效果稿链接和以下结论:
```markdown
- 静态设计验收已覆盖 320 × 568、360 × 640、360 × 800、412 × 915;四个尺寸均保持两个大按钮与单行文字注册入口。
- 此结论只验证设计稿与固定栅格,不替代 uni-app 页面实现后的 Android 运行时验收。
```
- [x] **Step 3: 运行设计记录审计并检查差异**
Run:
```powershell
powershell -NoProfile -ExecutionPolicy Bypass -File tests/a01-design-record-audit.ps1
git diff --check
```
Expected: `A01-DESIGN-RECORD-AUDIT PASS`,且 `git diff --check` 没有错误输出。
- [x] **Step 4: 更新阶段状态并提交**
仅当四张静态验收稿和 Task 3 资产审计均通过时,将 `docs/规划.md` 与 `docs/交接记录.md` 更新为“A-01 静态设计证据已完成,等待页面实现阶段的运行时验收”;不得写成页面已实现、接口已接通或 Android 已打包。
```powershell
git add docs/design/screens/A01-启动登录引导-栅格验收-320x568.png docs/design/screens/A01-启动登录引导-栅格验收-360x640.png docs/design/screens/A01-启动登录引导-栅格验收-360x800.png docs/design/screens/A01-启动登录引导-栅格验收-412x915.png docs/design/A01_启动登录引导_设计记录.md docs/规划.md docs/交接记录.md
git commit -m "docs: archive A01 static size evidence"
```
## Plan Self-Review
- 规格覆盖:入口层级由 Task 1 审计、Task 2 固定;协议、路由和状态由 Task 2 记录;独立背景、透明图标与微信来源由 Task 3 归档;四个 Android 尺寸由 Task 4 归档。
- 范围检查:所有任务限于设计记录、静态资源、效果稿和审计;没有 Vue、接口、登录逻辑或打包步骤。
- 一致性检查:A-02 只承接账号密码与手机验证码双标签;A-03 只承接微信授权;A-04 只承接注册;A-05 只承接忘记密码。
- 验收边界:四张效果稿是静态设计验证,不能替代页面实现后的真实 Android 运行时验收。
@@ -1,165 +0,0 @@
# A-01 V3 Visual Fidelity Implementation Plan
> 状态:已失效。用户否决“整张效果图作为页面视觉层”的方案;当前 A-01 必须以真实 DOM 还原 v3,见 `docs/design/A01_启动登录引导_设计记录.md`。
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Make the running A-01 page reproduce the selected `A01-启动登录引导-效果稿-v3-文字注册入口.png` at mobile width while preserving its existing local-only interactions.
**Architecture:** The accepted v3 image is a complete 852 × 1846 mobile visual, including typography, ornamental borders, header, ink scenery and the empty agreement circle. `pages/auth/entry.vue` renders it at a logical width of 750rpx and layers only transparent interaction regions above it. The existing `agreed` state and local-only toast/navigation functions remain the sole interaction owner.
**Tech Stack:** uni-app Vue 3 `<script setup>`, local PNG imported with `@/`, PowerShell contract audit.
## Global Constraints
- Visual source of truth: `docs/design/screens/A01-启动登录引导-效果稿-v3-文字注册入口.png`.
- Do not generate artwork, redraw v3 ornaments in CSS, add API calls, write login state, or create routes.
- A-01 stays vertically scrollable below 430 × 932; render the source with width-fixed scaling.
- A-01 is unchecked by default and retains the exact guard message `请先阅读并同意相关协议`.
- Only `pages/auth/entry.vue`, `tests/a01-a02-ui-contract.ps1`, and A-01 design/hand-off records may change.
---
### Task 1: Change the A-01 contract to the selected v3 source
**Files:**
- Modify: `tests/a01-a02-ui-contract.ps1`
**Interfaces:**
- Consumes: the local v3 visual source and existing `openLogin`, `prepareWechatLogin`, `prepareRegister`, `prepareAgreement` functions.
- Produces: a source-level guard that rejects the former hand-built header, title, cloud and button decorations.
- [ ] **Step 1: Write the failing visual-source assertions**
Add these checks while retaining agreement, route, local-toast and forbidden-API checks:
```powershell
Assert-Contains -Content $entry -Expected "import entryGuideVisual from '@/docs/design/screens/A01-启动登录引导-效果稿-v3-文字注册入口.png'" -Message 'A-01 must import the selected v3 visual source'
foreach ($requiredClass in @('guide-art', 'entry-login-hit', 'entry-wechat-hit', 'entry-register-hit', 'entry-agreement-hit')) {
Assert-Contains -Content $entry -Expected $requiredClass -Message "Missing A-01 interaction region: $requiredClass"
}
foreach ($forbiddenClass in @('auth-header', 'brand-intro', 'entry-actions', 'cloud')) {
Assert-NotContains -Content $entry -Unexpected $forbiddenClass -Message "A-01 must not redraw the v3 visual with $forbiddenClass"
}
```
- [ ] **Step 2: Run the contract test before changing the page**
Run: `& .\tests\a01-a02-ui-contract.ps1`
Expected: failure stating that A-01 does not import the selected v3 visual source.
- [ ] **Step 3: Do not commit red state**
Leave the failing contract uncommitted until Task 2 passes.
---
### Task 2: Replace the hand-built A-01 visual with the accepted v3 source
**Files:**
- Modify: `pages/auth/entry.vue`
**Interfaces:**
- Consumes: `entryGuideVisual`, `agreed`, `requireAgreement`, `openLogin`, `prepareWechatLogin`, `prepareRegister`, and `prepareAgreement`.
- Produces: the v3 presentation plus four screen-coordinate interaction regions.
- [ ] **Step 1: Import and render the accepted source width-fixed**
```vue
<script setup>
import { ref } from 'vue'
import entryGuideVisual from '@/docs/design/screens/A01-启动登录引导-效果稿-v3-文字注册入口.png'
</script>
<template>
<view class="entry-page">
<image class="guide-art" :src="entryGuideVisual" mode="widthFix" />
</view>
</template>
```
Set the root to `width: 750rpx; min-height: 1625rpx;` with no `overflow: hidden`, so 320 × 568 remains scrollable.
- [ ] **Step 2: Add only invisible v3 hit regions**
```vue
<view class="entry-hit entry-login-hit" hover-class="entry-hit-active" @click="openLogin" />
<view class="entry-hit entry-wechat-hit" hover-class="entry-hit-active" @click="prepareWechatLogin" />
<view class="entry-hit entry-register-hit" hover-class="entry-hit-active" @click="prepareRegister" />
<view class="entry-hit entry-agreement-hit" @click="toggleAgreement" />
<view v-if="agreed" class="agreement-confirmed"></view>
```
Use v3s 750rpx logical coordinates: login `top: 799rpx; left: 99rpx; width: 552rpx; height: 126rpx`; WeChat `top: 960rpx`; register `top: 1155rpx; left: 200rpx; width: 350rpx; height: 72rpx`; agreement toggle `top: 1362rpx; left: 126rpx; width: 60rpx; height: 60rpx`. Display the check marker only after a user toggle.
- [ ] **Step 3: Preserve the existing local-only behavior**
```js
const requireAgreement = () => {
if (agreed.value) return true
uni.showToast({ title: '请先阅读并同意相关协议', icon: 'none' })
return false
}
const openLogin = () => {
if (!requireAgreement()) return
uni.navigateTo({ url: '/pages/auth/login?agreed=1' })
}
```
Keep prepared-state toasts for WeChat, registration and agreement pages. Do not add `appApi`, `calcMD5`, `loginWithPassword`, `uni.reLaunch`, or an implicit agreement sentence.
- [ ] **Step 4: Run the focused test cycle**
Run: `& .\tests\a01-a02-ui-contract.ps1`
Expected: `A01-A02-UI-CONTRACT PASS`.
---
### Task 3: Record the v3 runtime source and verify
**Files:**
- Modify: `docs/design/A01_启动登录引导_设计记录.md`
- Modify: `docs/交接记录.md`
**Interfaces:**
- Consumes: selected v3 source and current A-01 local interaction contract.
- Produces: one documented visual source for the next Android screenshot comparison.
- [ ] **Step 1: Update the A-01 record**
State that v3 is the runtime visual source, not merely archived evidence. Correct the principal button copy to `登录`; its destination remains A-02s default account/password tab after explicit agreement.
- [ ] **Step 2: Update hand-off status**
Record that the first local preview diverged from v3 in header height, button width and vertical rhythm, and that the correction uses v3 directly. Keep four-size Android runtime comparison pending.
- [ ] **Step 3: Run project verification**
```powershell
$tests = Get-ChildItem -LiteralPath .\tests -Filter *.ps1 | Sort-Object Name
foreach ($test in $tests) { & $test.FullName }
git diff --check
git status --short
```
Expected: all scripts pass, `git diff --check` is silent, and only these four files changed besides user-owned dirty files.
- [ ] **Step 4: Commit scoped work**
```powershell
git add -- pages/auth/entry.vue tests/a01-a02-ui-contract.ps1 docs/design/A01_启动登录引导_设计记录.md docs/交接记录.md
git commit -m "fix: match A01 v3 visual reference"
```
---
## Self-Review
- [x] The target is the exact saved v3 image the user displayed, not a new interpretation.
- [x] The plan replaces hand-drawn visible ornaments with the existing visual asset and keeps interactions local.
- [x] The contract goes red before the page change, then verifies the visual source and interaction regions.
- [x] No API, new route, Android configuration, or non-A-01 screen is included.
- [x] Android runtime screenshot comparison remains follow-up work, not an unverified claim.
@@ -1,226 +0,0 @@
# 家谱 APP 地基重整 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:** 在不改变 A01、G01 已验收视觉结果的前提下,完成页面路由命名、图片资产包、代码中文注释和无引用资源清理的可维护地基。
**Architecture:** `pages.json` 继续作为唯一实际路由源;P00 文档提供页面语义映射。跨模块图片归入 `static/assets/foundation/{opaque,transparent}`,模块专用图片归入 `static/assets/modules/<module>/`。页面只引用语义路径;公共组件仅复用两个及以上已确认页面的稳定结构。
**Tech Stack:** Vue 3、uni-app、SCSS、PowerShell 审计脚本、HBuilderX Android 预览。
## Global Constraints
- Android `uni-app`;不新增依赖、接口、业务功能或未确认页面状态。
- A01、G01 在 412 × 915 的可见结果冻结,迁移后必须回归验证。
- 可见装饰一律为图片:`opaque` 负责完整底色,`transparent` 为 RGBA 叠加;禁止 CSS/SVG 装饰和截图切片。
- 页面路径采用 `<编号>-<语义名>.vue`;旧入口必须在所有引用更新并验证后删除。
- 关键结构、路由、资产图层和复杂逻辑使用中文注释;不得保留无用代码、资产、依赖或兼容入口。
---
### Task 1: 建立可重复的地基审计
**Files:**
- Create: `tests/foundation-structure-audit.ps1`
- Modify: `docs/design/P00_页面结构与资产清单.md`
- Test: `tests/foundation-structure-audit.ps1`
**Interfaces:**
- Consumes: `pages.json``pages[*].path`P00 的目标页面命名规则。
- Produces: 退出码 `0` 表示路由、文件、资产目录和中文文件头满足当前迁移阶段;非 `0` 输出具体缺失路径。
- [ ] **Step 1: 写出会失败的结构审计**
`tests/foundation-structure-audit.ps1` 定义下列检查:
```powershell
$pages = (Get-Content -Raw pages.json | ConvertFrom-Json).pages
foreach ($page in $pages) {
$file = "$($page.path).vue"
if (-not (Test-Path $file)) { throw "缺少路由页面:$file" }
if ($file -match '/(index|list|detail)\.vue$') { throw "最终页面名不允许使用:$file" }
if (-not (Get-Content -Raw $file -Encoding utf8).StartsWith('<!-- 页面编号:')) {
throw "缺少中文页面文件头:$file"
}
}
```
- [ ] **Step 2: 运行并确认当前版本失败**
Run: `powershell -ExecutionPolicy Bypass -File tests/foundation-structure-audit.ps1`
Expected: FAIL,指出当前 `index.vue`、`list.vue` 或缺少中文文件头的路由。
- [ ] **Step 3: 增加迁移阶段允许项**
在同一脚本中把未迁移前的旧路径列为显式 `legacyPaths`,并要求其只在迁移批次开始前存在;迁移完成后删除该数组。检查 `static/assets/foundation/opaque` 和 `static/assets/foundation/transparent` 两个目录存在。
- [ ] **Step 4: 运行审计确认其仅报告真实缺失项**
Run: `powershell -ExecutionPolicy Bypass -File tests/foundation-structure-audit.ps1`
Expected: FAIL,但每项错误都能对应 P00 中的未迁移项目。
### Task 2: 迁移页面文件、路由与中文文件头
**Files:**
- Modify: `pages.json`
- Move: `pages/auth/entry.vue` → `pages/auth/a01-entry.vue`
- Move: `pages/auth/login.vue` → `pages/auth/a02-login.vue`
- Move: `pages/genealogy/index.vue` → `pages/genealogy/g01-my-genealogies.vue`
- Move: `pages/genealogy/create.vue` → `pages/genealogy/g03-create-genealogy.vue`
- Move: `pages/lineage/first-person.vue` → `pages/genealogy/g04-first-ancestor.vue`
- Move: `pages/genealogy/detail.vue` → `pages/genealogy/g05-genealogy-overview.vue`
- Move: `pages/genealogy/search.vue` → `pages/genealogy/g06-search-genealogies.vue`
- Move: `pages/genealogy/applications.vue` → `pages/genealogy/g10-application-review.vue`
- Move: `pages/tree/index.vue` → `pages/tree/t01-tree-overview.vue`
- Move: `pages/member/detail.vue` → `pages/tree/t03-member-profile.vue`
- Move: `pages/family/index.vue` → `pages/family/f01-family-feed.vue`
- Move: `pages/content/editor.vue` → `pages/family/f02-publish-feed.vue`
- Move: `pages/content/list.vue` → `pages/skeleton/content-list-skeleton.vue`
- Move: `pages/notification/index.vue` → `pages/notification/n01-message-center.vue`
- Move: `pages/profile/index.vue` → `pages/profile/m01-profile-home.vue`
- Modify: every file that navigates to one of the 15 paths
- Modify: `docs/design/P00_页面结构与资产清单.md`
- Modify: `tests/a01-a02-ui-contract.ps1`, `tests/g01-visual-contract.ps1`
- Test: `tests/foundation-structure-audit.ps1`, `tests/compile-audit.ps1`, `tests/manifest-json.ps1`
**Interfaces:**
- Consumes: P00 第 2 节的逐项映射。
- Produces: `pages.json` 中每个路径指向一个存在、带中文文件头的语义页面文件。
- [ ] **Step 1: 搜索全部旧路由引用**
Run: `rg -n "pages/(auth/entry|auth/login|genealogy/index|genealogy/create|genealogy/detail|genealogy/search|genealogy/applications|lineage/first-person|content/list|content/editor|tree/index|member/detail|family/index|profile/index|notification/index)" --glob '!unpackage/**'`
Expected: 得到 `pages.json`、Vue 跳转和测试中的完整引用列表;将结果逐项写入 P00 的迁移备注。
- [ ] **Step 2: 移动一个文件并在文件头加入中文身份注释**
每个迁移页面使用固定文件头:
```vue
<!-- 页面编号:A-01;用途:启动/登录引导;视觉基准已由用户验收,重构时不得改变可见效果。 -->
<template>
```
A01、G01 文件头额外注明“视觉冻结”;`content-list-skeleton.vue` 明确注明“功能骨架,不计为设计完成页面”。
- [ ] **Step 3: 在同一批次更新路由和跳转**
`pages.json` 的路径去掉 `.vue` 后必须与新文件完全一致,例如:
```json
{ "path": "pages/auth/a01-entry", "style": { "navigationStyle": "custom" } }
```
所有 `uni.navigateTo`、`uni.reLaunch`、测试断言同步改为新 URL;不留下旧 URL 回退分支。
- [ ] **Step 4: 运行路由审计**
Run: `powershell -ExecutionPolicy Bypass -File tests/foundation-structure-audit.ps1`
Expected: PASS,所有 `pages.json` 路径对应真实 Vue 文件并带中文文件头。
- [ ] **Step 5: 运行现有编译与 manifest 审计**
Run: `powershell -ExecutionPolicy Bypass -File tests/compile-audit.ps1`
Expected: PASS,所有 `pages.json` 路由和本地模块引用均存在。
Run: `powershell -ExecutionPolicy Bypass -File tests/manifest-json.ps1`
Expected: PASS`manifest.json` 仍是有效 JSON。
### Task 3: 建立图片资产清单并迁移 A01/G01 资产
**Files:**
- Create: `static/assets/foundation/opaque/`
- Create: `static/assets/foundation/transparent/`
- Create: `static/assets/modules/genealogy/opaque/`
- Create: `static/assets/modules/genealogy/transparent/`
- Create: `tests/foundation-asset-audit.ps1`
- Modify: `pages/auth/a01-entry.vue`, `pages/auth/a02-login.vue`, `pages/genealogy/g01-my-genealogies.vue`, `components/PageHeader.vue`, `components/AppTabbar.vue`
- Modify: `tests/a01-a02-ui-contract.ps1`, `tests/a01-asset-alpha-audit.ps1`, `tests/g01-asset-alpha-audit.ps1`, `tests/g01-visual-contract.ps1`
- Modify: `docs/design/P00_页面结构与资产清单.md`
- Test: `tests/foundation-asset-audit.ps1`, `tests/a01-asset-alpha-audit.ps1`
**Interfaces:**
- Consumes: 当前已验收 A01G01 图片、D1 的 `opaque``transparent` 规则。
- Produces: 每一项保留图片有唯一语义路径、透明属性与至少一个明确代码引用。
- [ ] **Step 1: 写出会失败的资产审计**
`tests/foundation-asset-audit.ps1` 扫描 `pages/`、`components/`、`pages.json` 和测试内的 `/static/assets/` 路径;对每个路径检查文件存在。脚本还读取 P00 中的资产登记表,拒绝 `-v1`、`-v2`、`-source`、重复语义文件作为最终 `foundation/` 路径。
- [ ] **Step 2: 运行并确认当前版本失败**
Run: `powershell -ExecutionPolicy Bypass -File tests/foundation-asset-audit.ps1`
Expected: FAIL,指出当前资源仍在旧 `backgrounds/`、`icons/` 路径且没有最终登记。
- [ ] **Step 3: 按属性迁移已验收资源**
迁移时不修改像素内容,只改路径和名称:
```text
auth-rice-paper-v1.jpg -> foundation/opaque/auth-page-paper.jpg
auth-ancestral-header-v1.png -> foundation/opaque/auth-header.png
auth-ink-scenery-v1.png -> foundation/transparent/auth-ink-scenery.png
auth-title-cloud-v1.png -> foundation/transparent/auth-title-cloud.png
auth-divider-knot-v1.png -> foundation/transparent/auth-divider-knot.png
auth-button-corner-v1.png -> foundation/transparent/auth-button-frame.png
```
G01 的纸纹、页头、导航底图归 `opaque`;谱印、云纹、功能图标、Tab 图标、分隔纹归 `transparent`。如果某个当前按钮仍依赖 CSS 填色或四角拼接,记录为“待完整按钮图片替代”,不得把它伪装为已完成。
- [ ] **Step 4: 更新所有引用并写中文图层注释**
每个页面的图片层在模板中标明,例如:
```vue
<!-- 不透明纸纹底图:承担页面底色,不能由 CSS 替代。 -->
<image class="paper-background" src="/static/assets/foundation/opaque/auth-page-paper.jpg" mode="aspectFill" />
```
透明 PNG 注释注明叠加的宿主底图。不得新增新图片或改变 A01/G01 的尺寸、层级或 `mode`。
- [ ] **Step 5: 运行资产审计和透明度审计**
Run: `powershell -ExecutionPolicy Bypass -File tests/foundation-asset-audit.ps1`
Expected: PASS,所有代码图片引用存在且最终资产路径没有临时版本名。
Run: `powershell -ExecutionPolicy Bypass -File tests/a01-asset-alpha-audit.ps1`
Expected: PASSA01 中登记为透明的 PNG 保持 RGBA 属性。
### Task 4: 删除无引用旧资产并做视觉回归
**Files:**
- Delete: 资产审计确定为 `无引用删除` 或 `迁移后删除` 的旧文件
- Modify: `docs/design/P00_页面结构与资产清单.md`
- Test: `tests/foundation-asset-audit.ps1`, `tests/a01-a02-ui-contract.ps1`, `tests/compile-audit.ps1`
**Interfaces:**
- Consumes: Task 3 的通过资产审计和 P00 中的去留状态。
- Produces: 不存在无引用旧资产;A01、G01 仍引用唯一的语义资产。
- [ ] **Step 1: 输出删除候选并二次扫描**
Run: `rg -n "/static/assets/" pages components tests pages.json --glob '!unpackage/**'`
Expected: 每一个候选旧文件均不在输出中;若仍有输出,保留文件并回到 Task 3。
- [ ] **Step 2: 删除已确认路径并立即更新 P00**
仅删除 P00 表格中标记为 `无引用删除` 或 `迁移后删除` 的文件;不递归删除目录,不删除用户未确认的模块资产。
- [ ] **Step 3: 运行视觉与编译回归**
Run: `powershell -ExecutionPolicy Bypass -File tests/a01-a02-ui-contract.ps1`
Expected: PASS。
Run: `powershell -ExecutionPolicy Bypass -File tests/compile-audit.ps1`
Expected: PASS。
在 HBuilderX 以 412 × 915 复核 A01、G01:纸纹、页头、按钮、祥云、谱印、导航无断裂、无缺失、无拼接痕迹。
- [ ] **Step 4: 检查差异与文档状态**
Run: `git diff --check`
Expected: PASS。
将 P00 每个页面和资产的处理状态更新为真实结论;仅在上述检查均通过后将总规划的 P-00 标记为完成。
@@ -1,159 +0,0 @@
# G01 v5 Structural Alignment 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:** Rebuild G01s title slip and list hierarchy, and align its header, hall eave, cloud button, background, and Tabbar to the v5 reference.
**Architecture:** `pages/genealogy/index.vue` owns the current title slip, spacing, lower background, and new cloud asset. `GenealogyCard.vue` owns the two-row list entry. `PageHeader.vue` and `AppTabbar.vue` keep their existing APIs and receive only scoped CSS changes.
**Tech Stack:** uni-app Vue 3, scoped SCSS, local RGBA PNG, PowerShell static contracts.
## Global Constraints
- Android only: use rpx and flexbox; do not add CSS Grid, `gap`, `clip-path`, filters, routes, APIs, or data fields.
- Visual source: `docs/design/screens/G01-我的家谱-紧凑版设计稿-v5-Tabbar安全区.png`.
- Dynamic values remain Vue text; visible ornaments remain local transparent PNGs.
- At `max-width: 340px`, hide only `更新于`.
- Keep G-01 unchecked until HBuilderX evidence exists at `360 × 800` and `320 × 568`.
---
### Task 1: Add failing structural and asset contracts
**Files:**
- Modify: `tests/g01-visual-contract.ps1`
- Modify: `tests/g01-asset-alpha-audit.ps1`
**Interfaces:** Requires `create-cloud-v2.png`, `.current-summary`, `.card-main`, and `.card-title-row`; rejects the v1 asset and `.card-copy`.
- [ ] **Step 1: Replace the old assertions**
Replace v1 with v2 in both test asset lists. Add this contract block:
```powershell
if ($header -notmatch 'height:\s*164rpx') { throw 'Header height does not match the approved narrow red-head proportion.' }
if ($header -notmatch 'opacity:\s*\.29') { throw 'Header hall line-art is not visible enough for the approved eave layer.' }
if ($tabbar -notmatch 'font-size:\s*30rpx') { throw 'Tabbar label size does not match the approved visual weight.' }
if ($tabbar -notmatch 'width:\s*64rpx') { throw 'Tabbar icon size does not match the approved visual weight.' }
foreach ($token in @('current-summary', 'create-cloud-v2.png')) { if ($page -notmatch $token) { throw "G-01 current panel is missing $token" } }
foreach ($token in @('card-main', 'card-title-row', 'card-detail-row')) { if ($card -notmatch $token) { throw "G-01 list card is missing $token" } }
if ($page -match 'create-cloud-v1\.png' -or $card -match 'card-copy') { throw 'G-01 retains a superseded visual structure.' }
```
- [ ] **Step 2: Verify old code fails**
Run `powershell -ExecutionPolicy Bypass -File tests\g01-visual-contract.ps1`.
Expected: `FAIL` because v2, title-slip/list structure, and new shared sizes do not yet exist.
- [ ] **Step 3: Commit the failing contract**
```powershell
git add tests/g01-visual-contract.ps1 tests/g01-asset-alpha-audit.ps1; git commit -m "test: define G01 v5 structural alignment"
```
### Task 2: Replace the cloud ornament
**Files:**
- Create: `static/assets/icons/action/create-cloud-v2.png`
- Delete: `static/assets/icons/action/create-cloud-v1.png`
- Modify: `pages/genealogy/index.vue`
**Interfaces:** v2 is a 96px RGBA transparent antique-gold horizontal cloud ornament rendered once normally and once with the existing mirror class.
- [ ] **Step 1: Generate and inspect v2**
Create one transparent-background 96px asset: two compact, symmetrical antique-gold Chinese cloud curls joined by a short fine line; no text, shadow, red fill, border, or background. The artwork fills the middle 70% of the canvas and matches the references line weight.
- [ ] **Step 2: Install v2 and remove v1**
Point both button images to `/static/assets/icons/action/create-cloud-v2.png`. Set `.create-action` to `width: 350rpx`, `.create-cloud` to `74rpx × 36rpx`, `.create-action .create-icon` to `48rpx × 48rpx`, and its text to `34rpx`. Run `rg -n 'create-cloud-v1\.png' -g '!unpackage/**'`; expected no output. Remove v1 only after this check.
- [ ] **Step 3: Verify alpha and commit**
Run `powershell -ExecutionPolicy Bypass -File tests\g01-asset-alpha-audit.ps1`; expected `PASS static/assets/icons/action/create-cloud-v2.png transparent corners and visible artwork`.
```powershell
git add static/assets/icons/action/create-cloud-v2.png static/assets/icons/action/create-cloud-v1.png pages/genealogy/index.vue; git commit -m "feat: replace G01 create cloud ornament"
```
### Task 3: Rebuild the current title slip and list rows
**Files:**
- Modify: `pages/genealogy/index.vue`
- Modify: `components/GenealogyCard.vue`
**Interfaces:** Page owns `.current-summary` and full-width `.current-meta`; card owns `.card-main`, `.card-title-row`, and `.card-detail-row`. Props and `select` event stay unchanged.
- [ ] **Step 1: Replace current title-slip hierarchy**
Use this markup around the existing images and text bindings:
```vue
<view class="current-summary"><view class="current-seal"><image class="current-seal-frame" src="/static/assets/icons/genealogy/seal-current-frame-v1.png" mode="scaleToFill" /><text class="current-seal-title">家谱</text></view><text class="current-name">{{ currentGenealogy.name }}</text></view><view class="current-info-divider"></view><view class="current-meta"><view class="current-meta-item"><image class="current-meta-icon" src="/static/assets/icons/common/location-v1.png" mode="aspectFit" /><text class="current-meta-item-text">{{ currentGenealogy.location }}</text></view><view class="current-meta-item"><image class="current-meta-icon" src="/static/assets/icons/common/member-meta-v1.png" mode="aspectFit" /><text class="current-meta-item-text">{{ currentGenealogy.memberCount }} 位成员</text></view><view class="current-meta-item"><image class="current-meta-icon" src="/static/assets/icons/common/admin-v1.png" mode="aspectFit" /><text class="current-meta-item-text">管理员</text></view></view>
```
Delete `.current-copy`. Use `.current-slip { display: block; min-height: 268rpx; padding: 30rpx 34rpx 28rpx; }`, `.current-summary { display: flex; align-items: center; }`, `.current-meta { display: flex; justify-content: space-between; }`, and `.current-meta-item { margin: 0; }`.
- [ ] **Step 2: Replace the lists split columns**
Use this layout after the existing seal:
```vue
<view class="card-main"><view class="card-title-row"><text class="card-name">{{ genealogy.name }}</text><view class="card-side"><text class="card-role">{{ role }}</text><image class="card-chevron" src="/static/assets/icons/common/chevron-right-v2.png" mode="aspectFit" /></view></view><view class="card-detail-row"><view class="card-metas"><view class="card-meta-item"><image class="card-meta-icon" src="/static/assets/icons/common/location-v1.png" mode="aspectFit" /><text class="card-meta">{{ genealogy.location }}</text></view><view class="card-meta-item"><image class="card-meta-icon" src="/static/assets/icons/common/member-meta-v1.png" mode="aspectFit" /><text class="card-meta">{{ genealogy.memberCount }} 位成员</text></view></view><view class="card-updated"><text>更新于 {{ genealogy.updatedAt }}</text></view></view></view>
```
Use `.card-main { display: flex; min-width: 0; flex: 1; flex-direction: column; }`, `.card-title-row { display: flex; align-items: center; justify-content: space-between; }`, and remove the fixed-width `.card-side` column. Preserve `.card-updated { display: none; }` at 340px.
- [ ] **Step 3: Reduce spacing and expand landscape coverage**
Set `.genealogy-content` to `padding: 24rpx 32rpx 176rpx`; set `.page-footer-landscape` to `bottom: 112rpx`, `height: 480rpx`, and `opacity: 0.5`. Reduce only existing shortcut/list margins; do not alter handlers or states.
- [ ] **Step 4: Verify and commit**
Run `powershell -ExecutionPolicy Bypass -File tests\g01-visual-contract.ps1`; expected `PASS G-01 visual contract`.
```powershell
git add pages/genealogy/index.vue components/GenealogyCard.vue; git commit -m "feat: restructure G01 information hierarchy"
```
### Task 4: Calibrate shared shell, document, and verify
**Files:**
- Modify: `components/PageHeader.vue`
- Modify: `components/AppTabbar.vue`
- Modify: `docs/design/G01_我的家谱_设计记录.md`
- Modify: `docs/规划.md`
**Interfaces:** Header events and Tabbar routes remain unchanged; docs record implementation without checking off G-01.
- [ ] **Step 1: Implement narrow header and visible eave**
Set `.page-header--root` to `height: 164rpx`. Set `.header-hall` to `bottom: -2rpx`, `left: -48rpx`, `width: 476rpx`, `height: 166rpx`, and `opacity: .29`. Keep all click targets at least `88rpx` high.
- [ ] **Step 2: Compact the Tabbar while increasing its visual weight**
Set `.app-tabbar` to `height: 112rpx`, `.tab-item` padding to `4rpx 0`, `.tab-icon` to `64rpx × 64rpx`, and `.tab-label` to `30rpx`. Preserve the safe area and active/default assets.
- [ ] **Step 3: Record and run focused checks**
Append a dated record covering the new current slip, list rows, header/eave, compact spacing, landscape, and v2 cloud. Leave G-01 unchecked in `docs/规划.md`. Then run:
```powershell
powershell -ExecutionPolicy Bypass -File tests\g01-visual-contract.ps1; powershell -ExecutionPolicy Bypass -File tests\g01-asset-alpha-audit.ps1; powershell -ExecutionPolicy Bypass -File tests\compile-audit.ps1; powershell -ExecutionPolicy Bypass -File tests\uni-scss-injection.ps1; powershell -ExecutionPolicy Bypass -File tests\vue3-entry.ps1
```
Expected: five `PASS` results. Capture HBuilderX at `360 × 800` and `320 × 568`; only a direct v5 comparison can complete G-01.
- [ ] **Step 4: Commit**
```powershell
git add components/PageHeader.vue components/AppTabbar.vue docs/design/G01_我的家谱_设计记录.md docs/规划.md; git commit -m "feat: align G01 shared visual shell"
```
## Plan self-review
- Task 1 locks every structural and asset change before runtime edits.
- Task 2 replaces only the mismatched cloud asset and removes its old path.
- Task 3 covers title-slip/list structure, compact spacing, and landscape coverage.
- Task 4 covers header/eave, Tabbar, docs, static checks, and HBuilderX evidence.
@@ -1,29 +0,0 @@
# 真实接口核心流程设计
## 目标
把现有静态界面升级为可在 Mock 和远端接口之间明确切换的核心流程:登录后进入我的家谱,按当前家谱 ID 查看详情、世系树和成员,并完成创建家谱后录入首代人物。
## 方案选择
- 采用显式 `mode: 'mock' | 'remote'` 配置;远端请求失败时显示错误,不自动切回 Mock。
- 认证支持密码登录和短信登录。密码登录用于已有测试账号;短信入口按 OpenAPI 请求验证码和登录。
- API 适配层统一解包 OpenAPI 的 `{ code, msg, data }` 响应,统一保存/清除 token,并在 401/403 时提示重新登录。
- 当前家谱 ID 以页面参数为主、持久化上下文为辅。详情、树、成员、审核、家族动态不得再写死 `1001`
## 范围
本阶段完成认证、会话、家谱上下文、创建家谱、录入首代、详情、世系树、成员档案的真实数据链路。
本阶段不实现谱文、相册、祭祀、VIP、帮助、反馈等内容模块;这些入口继续明确提示“后续开放”。
## 验收
1. Mock 模式下可完成:登录、创建家谱、录入首代、进入对应世系树、打开对应成员档案。
2. Remote 模式下所有请求携带 `clientid` 与已保存 token,并按接口响应的 `data` 字段消费。
3. 远端登录/接口失败时不会跳转成功、不会静默返回 Mock 数据,并能给出可理解提示。
4. 页面跳转不再依赖硬编码家谱 ID 或成员 ID。
## 已知外部限制
当前远端密码登录探测返回 HTTP 500,前端会保留远端模式和错误提示,但无法在服务端恢复前完成真实账号的端到端验收。
@@ -1,62 +0,0 @@
# G01 参考稿信息层级校正设计
## 已确认的目标
-`docs/design/screens/G01-我的家谱-紧凑版设计稿-v5-Tabbar安全区.png` 为唯一视觉基准;不再以当前浏览器页为准缩放设计。
- 本轮只校正用户箭头指出的内容区:当前家谱题签的信息层级、四个快捷入口、分隔线之后的两条列表和“新建家谱”按钮。
- 保留已确认的朱砂页头、宣纸底纹、淡墨山水和底部 Tabbar;不接入接口、不改路由;仅为现有展示用 `data/mock.js` 家谱条目补充参考稿需要的 `updatedAt` 日期字段。
- 预览阶段的展示字段必须与参考稿一致:当前家谱为“汤氏家谱 / 河南·洛阳 / 158 位成员 / 管理员”,已加入家谱为“汤氏宗谱 / 山东·济宁 / 286 位成员 / 成员”。视觉验收期间不得保留四川、达州、刘氏等旧演示字段。
## 方案比较与选择
1. 仅增大现有 CSS 字号和图标容器:改动少,但仍缺地点/成员/管理员图标和更新时间层级,不能还原参考稿。
2. 同时压缩所有内容区:会牺牲旧安卓小屏可读性,也无法解决信息结构错误。
3. **按参考稿重建四个内容区的信息栅格**:补齐独立 PNG 元信息图标,放大可见图标和标题字号,使用两行列表元信息与右侧角色列。选此方案。
## 页面规格
### 当前家谱题签
- 保持纸签框资产和透明四角;名称为最高层级,使用楷体回退链。
- 左侧“家谱”不是普通红色圆角块:改为独立的竖式朱砂谱印框 PNG,包含参考稿的双层金白描边与红印底;谱印固定显示“家谱”,家谱名称和数值仍由页面数据渲染,不嵌死在图片中。
- 当前家谱谱印比列表谱印更高;两者分别使用对应尺寸的 PNG 框,保持参考稿的层级,不能共用一个拉伸框。
- 元信息改为三组:地点、成员数、管理员;每组由 24dp 的透明 PNG 图标与文字构成,不能再只用文字串联。
- 三组在可用宽度内均匀排列;320px 小屏可换行但不重叠。
- 名称与三组元信息之间保留参考稿中的细金线分隔;在 430px 验收宽度三组必须同一行。
### 快捷入口与分隔线
- 快捷图标的显示框放大,使实际图形接近参考稿的视觉面积;标签同步提升,四项保持等宽。
- 快捷区与金线云纹分隔线保留明确留白,分区标题不得贴线。
### 家谱列表
- 每条维持纸签框和透明四角;标题提升为主要信息。
- 左侧使用较小的竖式朱砂谱印框 PNG;已加入家谱可保持低饱和底色,但边框、比例与“我创建的”谱印一致。
- 次行固定为“地点 + 成员数”;右侧固定为“角色 + 箭头”,并在宽屏下补充“更新于日期”。
- 小屏降级时优先保留标题、地点、成员数和角色;更新时间可隐藏,禁止文字压到箭头。
- 角色文字与右箭头使用同一水平基线;日期是文本信息,不增加参考稿中没有的日历图标。
- 列表谱印固定显示竖排“家谱”,不显示动态姓氏;已加入列表使用低饱和谱印状态。
### 新建按钮
- 保留独立 PNG 加号;放大文字和图标实际显示尺寸。
- 补齐参考稿的左右云纹装饰 PNG,不用 CSS 或文字模拟装饰。
## 资产与兼容性
- 新增地点、成员、管理员、更新时间和按钮云纹均为项目内 RGBA PNG;图标画布为 96px,并检查四角透明。
- 不用 `clip-path`、蒙版或其他旧 Android WebView 风险较高的裁切方案。
- 所有布局继续使用 rpx、弹性布局、单行省略和 `max-width: 340px` 小屏规则。
## 验收与验证
1. 自动契约先失败:要求新元信息图标、两行列表结构、放大的快捷图标和新建按钮云纹存在;旧的纯文字元信息实现必须不再通过。
2. 完成最小代码与 PNG 资产改动后,运行 G01 契约、编译审计、SCSS 注入审计和 PNG 透明角检查。
3. 使用 HBuilderX 在同一设备尺寸重新截图,与参考稿并排比对:重点确认箭头所指的字号、间距、图标面积、列表行高与按钮装饰。
## 自检
- 无占位项、无接口或路由扩展。
- 唯一视觉来源是已确认的 G01 v5 参考稿;本轮没有重设颜色、背景或页面功能。
- 若 320px 宽度装不下,按本文件定义隐藏更新时间,而不是压缩全部文字。
@@ -1,33 +0,0 @@
# A-01 / A-02 本地 UI 落地设计
> 状态:用户已选择“完整前端落地,不接接口”。
>
> A-01 的视觉与入口规则唯一归属 [`docs/design/A01_启动登录引导_设计记录.md`](../../design/A01_启动登录引导_设计记录.md)。本文件是 A-01/A-02 本地 UI、路由和无接口交互的唯一实现合同。
## 页面与路由
- 新增 `pages/auth/entry.vue` 作为 A-01,并把它放在 `pages.json` 的第一个页面,成为应用启动入口。
- 保留 `pages/auth/login.vue`,但删除旧的 `appApi``calcMD5`、手机号密码接口和成功重启逻辑;该页改为 A-02。
- A-01 点击“登录”且已主动勾选协议后,使用 `uni.navigateTo` 进入 `/pages/auth/login?agreed=1`
- A-01 未勾选协议时,登录、微信登录、注册账号均只显示“请先阅读并同意相关协议”,不跳转、不改变勾选状态。
- A-01 已勾选协议后,微信登录显示“微信登录功能准备中”,注册账号显示“注册页面准备中”。不创建 A-03/A-04 的伪页面。
## A-01 视觉结构
- 使用现有 `static/assets/backgrounds/auth-ancestral-header-v1.png``auth-rice-paper-v1.jpg``auth-ink-scenery-v1.png` 作为独立认证页背景;使用 `login-outline-v1.png``wechat-licensed-v1.png` 作为本地图标。
- 顺序固定为:朱砂家祠头图、标题“家谱”、副标题“为家族留存可传承的记忆”、朱砂主按钮“登录”、描边按钮“微信登录”、单行文字“还没有账号?注册账号”、未勾选协议行。
- A-01 的运行时布局唯一遵循 `docs/design/A01_启动登录引导_设计记录.md` 中的“412 × 915 运行时校准表”;本文件不再重复或定义按钮与间距数值。
## A-02 双标签登录页
- 页头沿用认证页的朱砂头图与暖宣纸背景,但页面标题为“登录家谱”。
- 默认标签是“账号密码登录”;第二标签是“手机验证码登录”。只有当前标签的表单可见。
- 账号密码表单字段为“账号/手机号”“密码”,并展示“忘记密码”文字入口;手机验证码表单字段为“手机号”“验证码”,展示不可调用接口的“获取验证码”次级按钮。
- 两个标签的主按钮均为“登录”。点击时不发起网络请求:当 A-01 协议状态未通过查询参数传入时提示“请先返回登录引导确认协议”;通过时提示“登录接口待接入”。
- 忘记密码提示“忘记密码页面准备中”。不保留“登录即表示同意”或任何默认同意文案。
## 范围与验证
- 只修改 `pages.json`、新增 `pages/auth/entry.vue`、改造 `pages/auth/login.vue`,并新增针对路由、接口移除与协议文案的 PowerShell 合同审计。
- 不引入依赖、不调用 API、不写入 token、不跳转至家谱首页、不改未设计页面。
- 静态验收覆盖 320 × 568、360 × 640、360 × 800、412 × 915;页面实现后再在 HBuilderX 或 Android 运行环境验收实际布局。
@@ -1,21 +0,0 @@
# A-01 登录入口层级调整设计
> 状态:用户已确认视觉与交互方向;效果稿 v3 已归档,小屏布局验收待完成。
>
> 当前设计事实唯一归属 [`docs/design/A01_启动登录引导_设计记录.md`](../../design/A01_启动登录引导_设计记录.md)。本文件只记录本次确认的调整与其边界,不重复充当设计源。
## 已确认的调整
- A-01 不再并列展示“账号密码登录”“手机验证码登录”“微信登录”“注册账号”四个按钮。
- A-01 保留朱砂红“登录”主按钮与古金描边“微信登录”按钮。
- “还没有账号?注册账号”置于按钮下方,其中“注册账号”是朱砂红文字入口。
- “登录”进入 A-02;A-02 通过“账号密码登录 / 手机验证码登录”双标签承接两种登录方式,默认账号密码登录。
- 微信授权流程仍属 A-03;注册仍属 A-04;忘记密码仍属 A-05。
- 协议复选框默认未勾选。登录、微信、注册三种进入操作均不得默认视为用户同意协议;未勾选时仅提示先确认协议。
## 视觉与范围
- 保留已确认的家祠朱砂红头、暖宣纸、古金云纹、墨褐标题、淡墨建筑与山水背景方向。
- 已归档效果稿 v3`docs/design/screens/A01-启动登录引导-效果稿-v3-文字注册入口.png`
- v1、v2 效果稿保留为历史,不删除、不覆盖,且不能作为当前布局依据。
- 本次只调整设计记录、效果稿与后续验收口径;不改 uni-app 页面、接口、真实登录或 Android 打包。
@@ -1,20 +0,0 @@
# A-01 启动 / 登录引导已确认设计
> 状态:设计方案已于 2026-07-13 获用户确认;尚未完成视觉验收或页面实现。
>
> 唯一需求所有者:[A01 启动登录引导设计记录](../../design/A01_启动登录引导_设计记录.md)。本文件只记录本次审批,不重复定义页面规则。
## 审批结论
- A-01 延续已归档的“家祠卷轴”欢迎页美术锚点。
- 账号密码登录为朱砂主按钮;手机验证码登录、微信登录和独立的注册账号按钮依次位于其下。
- 注册账号入口直达 A-04A-02、A-03、A-05 的职责边界不改变。
- 协议默认不勾选,禁止“登录即同意”。
- 本审批仅启动 A-01 的设计工作;不授权任何 uni-app 页面实现、接口联调、真实登录或 Android 打包。
## 后续关口
1. 生成并归档 A-01 效果稿 v2。
2. 以固定 Android 栅格补齐正常与协议未勾选提示状态。
3. 生成并验收本地透明 PNG 功能资产。
4.`320 × 568``360 × 640``360 × 800``412 × 915` 完成小屏视觉验收后,才可标记 A-01 设计完成。
@@ -1,55 +0,0 @@
# A-01 启动/登录引导页设计
## 目标与边界
A-01 是 Android 用户端首次进入应用时的登录方式选择页。它只提供三个入口:账号密码、手机号验证码、微信登录;不承载输入表单、注册、忘记密码、登录失败或微信授权失败。
本轮只完成视觉与本地交互展示,不接接口、不接真实微信授权、不处理真机状态栏和打包。
## 页面归属与跳转
| A-01 操作 | 本地页面去向 | 说明 |
| --- | --- | --- |
| 账号密码登录 | A-02 | A-02 承载账号、密码输入与忘记密码入口。 |
| 手机号验证码登录 | A-03 | A-03 承载手机号、验证码与未注册承接。 |
| 微信登录 | A-03 | A-03 承载微信授权回跳展示;真实授权留到接口阶段。 |
| A-03 识别为未注册 | A-04 | A-04 承载注册及注册场景的协议确认。 |
因此 A-01 不显示“注册账号”或“忘记密码”,也不展示 A-06 的失败、取消或受限状态。
## 协议同意规则
协议同意必须是用户的主动选择,不能以“登录即表示同意”替代。
1. 页面初始时复选框为空,不预选。
2. 文案固定为“我已阅读并同意《用户协议》与《隐私政策》”;两份协议是独立的朱砂红文字链接。
3. 用户点击任一登录入口而尚未勾选时,停留在 A-01 并给出“请先阅读并同意相关协议”的本地提示;不得跳转、不得视为已同意。
4. 勾选后才允许进入对应的本地下一页。复选框的勾选状态使用朱砂红对勾和古金外圈。
5. A-04 的注册协议确认保留,因为它是注册提交的独立确认,不与 A-01 的登录前同意混为同一个控件。
## 视觉构成
以用户确认的登录效果图为美术锚点,并遵循 D1 的“独立红头/纸纹欢迎背景”规则:
- 顶部:整宽朱砂红祠堂线描,中央小型“谱”印章,下沿为古金海浪纹;不使用 G-01 的顶部导航或底部 Tab。
- 正文:暖白宣纸纹,左侧淡墨亭台、右侧淡竹影,页面尾部才出现低对比山水,不能压住操作和协议文字。
- 标题:中央墨色“家谱”,两侧小云纹;副句为“为家族留存可传承的记忆”,上下用细古金线收束。
- 操作:三个等宽矩形纸签按钮,左右安全边距 `56rpx`;高度不少于 `88rpx`;按钮之间 `24rpx`。主按钮为朱砂实底、暖白账号图标和文字;手机号按钮为纸白底古金双线和手机图标;微信按钮同为纸白金线,仅微信图标保持官方绿色。
- 协议:放在第三按钮之后、底部山水之前。复选框触控区不小于 `88rpx × 88rpx`,文字不低于 `24rpx`,与山水保持足够对比度。
## 资产与数据约束
- 背景继续使用项目内的独立祠堂红头、宣纸、淡山水/竹影资产;不得使用远程背景 URL,也不能把列表页的大山水直接复用为登录背景。
- 微信标识只在该真实入口使用。后续实现时从官方或许可来源取得透明 PNG,保存到 `static/assets/icons/`,记录来源、用途、路径与透明角检查结果;不重绘、不改色。
- A-01 的登录方式、文案、协议勾选和跳转只使用本地静态状态;本轮不读取 `appApi`,不发起请求。
## 验收标准
1. `360 × 800dp` 首屏同时看到红头、标题、三个登录入口和协议勾选。
2. 账号密码为唯一朱砂实底主操作;手机号验证码与微信为同层替代入口。
3. 初始画面没有“登录即同意”、没有预勾选协议、没有注册和忘记密码入口。
4. 未勾选协议点击任一登录入口不跳转;勾选后才按表格进入 A-02 或 A-03。
5. 页面没有底部 Tab、接口加载、真实授权、状态栏兼容处理或 Android 打包改动。
# 已失效历史记录(2026-07-13
> 本文件不再是 A-01 的设计或实现依据。唯一有效的 A-01 归档为 `docs/design/A01_启动登录引导_设计记录.md`;当前工作仍以 `docs/交接记录.md` 的 G-01 验收为准。
@@ -1,42 +0,0 @@
# A01 参考图拆分素材重做设计
## 目标
`docs/design/screens/A01-启动登录引导-栅格验收-412x915.png` 为唯一视觉真值,修正当前 A01 在 412 × 915 运行截图中与参考图不一致的红头、场景、标题、分隔中结和按钮边框。保留既有真实 DOM、协议状态与本地登录跳转,不接接口、不把整张设计图作为页面内容。
## 方案选择
1. 继续使用泛生成装饰素材:已验证会产生粗亮的 L 型角花和错误云纹,拒绝。
2. 从已确认的参考图拆分独立素材并由 DOM 组装:可以保留参考图中的金线、卷角和家祠细节,同时保留按钮、协议和点击逻辑。采用此方案。
3. 直接将 412 × 915 图整页铺底:会破坏响应式与交互,且违背 A01 的既有实现约束,禁止。
## 视觉组件与所有权
| 组件 | 唯一来源 | 页面使用方式 |
| --- | --- | --- |
| 红头与谱印 | 参考图顶部 412 × 139 区域 | `auth-header-reference-v2.png` 作为独立头图;移除额外谱印叠层 |
| 宣纸场景 | 参考图正文的建筑、竹影、山水,清除前景控件后形成独立场景层 | `auth-ink-scenery-v2.png` 覆盖正文,不含标题、按钮或协议文案 |
| 品牌标题及两侧祥云 | 参考图的标题装饰 | 可缩放透明 PNG,保留原有墨褐字形和古金云纹;不依赖运行环境字体 |
| 分隔中结 | 参考图上下分隔线的中心云结 | 单独透明 PNG,保留 CSS 细线 |
| 两类按钮边框 | 参考图中对应按钮的边缘、双描线与四角卷纹 | 主/次按钮各一张透明边框 PNG,按钮底色、图标、文字和点击仍为 DOM |
原有 `auth-button-corner-v1.png``auth-divider-knot-v1.png``auth-title-cloud-v1.png` 是不符合参考图的泛生成资产;本次完成后不得再由 A01 引用。
## 布局与交互
- 保留已接近参考图的固定纵向锚点:红头 139px、标题区起点、两按钮间距、注册入口和协议行位置。
- 标题采用参考字形素材,按钮文字及协议文案保持可读、可点击的 DOM 文本。
- 协议复选框与文案继续为单一水平行,保留现有未同意拦截和 `agreed=1` 跳转。
- 在窄屏时维持纵向滚动和安全区;所有新图层按页面宽度缩放,不写死页面宽度。
## 验收与测试
1. 先更新 A01 合同测试,要求 v2 素材被引用,并明确拒绝 v1 泛生成装饰;运行测试确认失败。
2. 生成或拆分 v2 素材,校验 PNG 尺寸、透明边角和可见像素;更新 `entry.vue` 后使合同与编译审计通过。
3. 在 HBuilderX/Android 获取无浏览器边框的 412 × 915 截图:检查红头底部瓦当、场景位置、标题比例、两个按钮边框与协议行。
4. 复核 320 × 568 可滚动性和协议安全区;未获得两张运行图不得宣称视觉验收通过。
## 范围外
- A02 及后续认证页面、真实登录、接口、Android 打包配置。
- 整页设计图铺底、未授权的字体下载、与本次视觉差异无关的组件重构。
@@ -1,24 +0,0 @@
# A-01 运行时视觉校准设计
## 目标与范围
`docs/design/screens/A01-启动登录引导-栅格验收-412x915.png` 的协议未勾选状态作为唯一视觉基准,把 `pages/auth/entry.vue` 的真实 DOM 校准到同一比例。此次只修正标题区、两条分隔饰线、双按钮、注册入口和协议行;现有认证背景图不在本次视觉校准范围内,接口、路由和 A-02 均不变。
## Contract ownership
`docs/design/A01_启动登录引导_设计记录.md` owns the calibrated 412 × 915 coordinate table and acceptance thresholds. This implementation specification records scope and component strategy only; it does not define duplicate layout values.
## 组件策略
- 页面继续使用真实 DOM 与现有本地认证背景,不使用整图作为页面内容。
- 标题两侧云纹和标题分隔线中心饰物保持独立语义元素;注册链接不得增加设计图中不存在的左右短线。
- 两个按钮共享经过校准的外框尺寸,不使用通用“方括号”角饰;直线边框由 scoped CSS 承担。
- 登录图标保持白色、微信图标保持其许可的原色;按钮文字按参考图缩小并移除全局通用按钮样式造成的额外字重或圆角干扰。
- 视觉常量只定义在 `pages/auth/entry.vue` 的 A-01 样式中;旧的 A-01 实现合同中互相冲突的 `638rpx/108rpx` 说明不再作为运行时布局依据。
## 验收
1. 运行 `tests/a01-a02-ui-contract.ps1`,确认协议与本地路由合同不变。
2. 在 HBuilderX 或 Android 以 `412 x 915` 截取协议未勾选状态,去除浏览器外框后与参考图同尺度叠加比对。
3. 确认红头、标题、两条分隔饰线、按钮四边、注册入口和协议行的误差均不超过约 2px;再复核 `320 x 568` 不会裁切协议或安全区。
4. 没有这两张运行截图,不能声明视觉校准完成。
@@ -1,27 +0,0 @@
# 家谱 APP 地基重整设计
## 目标
在不改变用户已验收 A01、G01 视觉结果的前提下,建立唯一、可维护的页面目录、路由映射、图片资产包、公共组件和中文注释规范,为其余 57 个页面/状态单元提供稳定地基。
## 已确认约束
- Android `uni-app`;不增加依赖、不接新接口、不做额外功能。
- A01、G01 是冻结视觉锚点,必须在 412 × 915 回归验证。
- 可见装饰均使用图片:不透明图片承担完整底色和外观;透明 PNG 仅作为叠加元素;CSS 不绘制装饰。
- 禁止整页截图、局部切片、拼接背景、拼装按钮和保留重复/未引用资产。
- 页面文件采用页面编号和语义名;旧路径只有在所有引用更新并验证后才能删除。
- 所有非直观结构、逻辑、路由和资产图层使用中文注释;实现只覆盖确认需求。
## 架构
`docs/design/P00_页面结构与资产清单.md` 管理页面与资源的映射。`static/assets/foundation/opaque``transparent` 承载跨模块资源,模块专用资源放入 `modules/<module>/``pages.json` 是实际路由唯一来源,页面、跳转和测试与其同轮迁移。
公共组件只沉淀已被两页以上使用且视觉稳定的结构。页面内部无法确认复用的内容不抽象,避免万能组件和预留逻辑。
## 验收
1. 当前 15 个路由在 P00 中均有明确用途和目标文件名。
2. 每个保留图片可追溯用途、透明属性和代码引用;删除前后二次扫描均无引用。
3. A01、G01 在 412 × 915 下没有可见视觉回退。
4. 相关审计、编译检查与 `git diff --check` 通过。
@@ -1,60 +0,0 @@
# G01 v5 视觉对齐设计
> 状态:根据最新预览反馈修订,待确认
> 视觉依据:`docs/design/screens/G01-我的家谱-紧凑版设计稿-v5-Tabbar安全区.png`
## 目标与边界
将当前 G01「我的家谱」页面的运行预览校正至 v5 参考稿的比例、信息层级和对比度。本轮只处理可见的正常态页面,不改变路由、交互、`data/mock.js` 字段或远程接口;仅为“新建家谱”更换一个参考稿匹配的透明祥云 PNG。
涉及文件仅限:
- `pages/genealogy/index.vue`
- `components/GenealogyCard.vue`
- `components/PageHeader.vue`
- `components/AppTabbar.vue`
- `static/assets/icons/action/create-cloud-v2.png`
- `tests/g01-visual-contract.ps1`
- `docs/design/G01_我的家谱_设计记录.md`
- `docs/规划.md`
## 方案选择
1. 只微调字号和间距:改动最少,但无法让当前题签的元信息跨整行排列,也无法解除列表字段的截断。
2. **结构重排加比例校正(采用)**:将当前题签改为两层信息结构、列表改为主内容两行结构,再对页头、列表、按钮、底栏与背景做 CSS 尺寸校正;保留现有数据与路由。
3. 重新绘制整套 PNG:可进一步调整所有图标墨色,但改动范围大;本轮只重绘当前页面确实不匹配的祥云 PNG。
## 视觉设计
### 页头、屋檐与底栏
- 根页头从当前比例收窄;Logo、标题与通知仍保留各自安全区,标题置于缩窄后红头的视觉中线。
- 放大现有 `header-hall-lineart.png` 的覆盖面积并提高不透明度,使屋檐在页头左下可辨识,同时不遮挡 Logo 和标题;不新增祠堂背景资产。
- Tabbar 收紧容器高度,同时放大 Tab 图标和标签,使其接近参考稿的安全区内视觉重量。
### 当前家谱题签与页面间距
- 将题签分成上方“谱印 + 名称”和下方“分隔线 + 三组元信息”两层;元信息横跨题签底部并均匀分布,而不是挤在名称右侧。
- 保留现有谱印框、地点/成员/管理员 PNG 与动态文本;不嵌入文字到图片中。
- 恢复可见的细金线分隔,提升元信息及快捷入口的墨色对比和可读尺寸。
- 内容区水平边距从当前偏宽的值缩小,题签、列表和分隔线使用同一对齐线;快捷入口与列表之间只保留参考稿需要的留白。
### 列表、新建按钮与背景
- 列表从“左侧内容列 + 独立右侧列”改为单一主内容区:第一行是 `标题 / 角色 / 箭头`,第二行是 `地点 / 成员数 / 更新于`。这让日期可借用角色下方空间,360px 宽度完整显示,320px 时仅隐藏日期。
- 收窄新建按钮,放大“+ 新建家谱”与两侧云纹的可见面积。以独立的 `create-cloud-v2.png` 替换当前与参考稿不一致的 `create-cloud-v1.png`;新资产为紧凑 RGBA 透明 PNG,并在确认没有其他消费者后移除旧资产。
- 扩大底部山水竹影的覆盖范围与可见层次,但保持它在文字和点击区域之下。
## 验收与验证
1. 先为题签两层结构、列表两行结构、新祥云 PNG 与关键尺寸写 G01 静态契约,并确认旧实现失败。
2. 生成、检查并接入 `create-cloud-v2.png`;确认四角透明、有效图形紧凑,且旧祥云 PNG 没有剩余消费者。
3. 修改组件后运行 `g01-visual-contract.ps1``g01-asset-alpha-audit.ps1``compile-audit.ps1``uni-scss-injection.ps1``vue3-entry.ps1`
4. 在 HBuilderX 的 `360 × 800` 重新截图,并同 v5 参考稿逐区比对:页头屋檐、当前题签、快捷入口、两条列表、新建按钮和 Tabbar。
5. 再检查 `320 × 568`:不重叠、不截断、不被底栏遮挡。截图验收完成前,`G-01` 保持未完成状态。
## 自检
- 不含占位项或未定义的视觉来源。
- 组件职责不变:页面负责当前题签和新建按钮,卡片负责完整的两行列表条目,页头和 Tabbar 保持独立。
- 不保留旧的独立 `.card-side` 列或旧祥云 PNG 引用;320px 降级规则只隐藏日期,其他参考稿字段仍保留。
-110
View File
@@ -1,110 +0,0 @@
# 家谱 APP 交接记录
> 更新时间:2026-07-13
> 用途:更换电脑或更换 GPT 后,从本文件恢复项目上下文。
> 当前阶段:P-00 地基重整收尾,**不要跳到 A02**。
## 1. 新接手者先读什么
1. 根目录 `AGENTS.md`:最小修改、先验证、不得覆盖未知改动。
2. [总规划](规划.md):59 个页面的范围、当前状态和推进顺序。
3. [D1 安卓视觉地基规范](design/D1_安卓视觉规范与页面壳.md):视觉、资产和注释硬规则。
4. [P00 页面结构与资产清单](design/P00_页面结构与资产清单.md):页面重命名映射、资产去留和清理进度。
5. A01、G01 设计记录与下列冻结锚点:
- `docs/design/screens/A01-启动登录引导-栅格验收-412x915.png`
- `docs/design/screens/G01-我的家谱-紧凑版设计稿-v5-Tabbar安全区.png`
## 2. 当前事实
- 项目:Android `uni-app`,使用 HBuilderX;不做 iOS、H5、PC、后台或官网。
- 全量规划是 59 个页面/状态单元;`pages.json` 当前只有 15 个已注册路由。
- 用户已通过视觉验收的只有两个页面:A01 启动/登录引导、G01 我的家谱。
- A01、G01 是冻结视觉基准:可以抽取和整理内部实现,但不得改变用户已验收的可见效果。
- 其余页面现有代码只算功能骨架或本地 UI,不能标记为视觉完成。
- 已完成:15 个路由的语义化文件/跳转迁移、35 项在用资产迁入统一目录、24 项运行时零引用旧资产删除、公共页面/组件中文用途注释。
- A01 主/次完整按钮候选已生成并保存到项目,但比例尚不符合现有设计,尚未接入页面;当前第一优先级仍是 P-00 收尾:复核/重做候选,验收后替换旧角标拼接,再做 A01、G01 截图回归;不是继续 A02,也不是接接口。
## 3. 用户明确确认的规则
- 以 A01、G01 已完成样式为项目地基;不能推翻或另起一套画风。
- 资产先行:先形成一套可复用、已验收的资产包,再按资产包设计后续页面。
- 所有可见装饰使用图片资产;不使用 CSS/SVG 绘制祥云、边框、分隔纹、按钮边角等可见装饰。
- 不透明图片承担自身底色、纹理、完整按钮/卡片/页头背景;透明 PNG 只承担 Logo、图标、祥云、纹样、边框等叠加元素。
- 禁止把整页设计图或局部截图裁切后拼进页面;禁止按钮由角标、边线和 CSS 底色拼接。
- 页面、路由、资产都必须用语义名和页面编号;旧文件、重复版本和未引用资产确认后删除。
- 关键 JS、Vue 结构、资产图层与关键样式必须有中文注释;注释说明用途和约束,不堆砌无意义逐行注释。
- JS 和页面代码只做已确认的需求:不发散、不预埋猜测功能、不新增无关依赖、不保留无用代码。
## 4. P-00 应按什么顺序执行
1. 运行 `git status --short`,保留未知改动;不得使用 `git reset --hard` 或覆盖用户文件。
2. 依据 P00 文档核对 15 个当前路由的页面编号和目标语义名。
3. 扫描所有 `/static/assets/` 引用,填写每项资产的用途、透明属性、引用位置与去留状态。
4. 建立新的 `foundation/opaque``foundation/transparent``modules/<module>` 资产结构和资产清单。
5. 同轮更新 `pages.json`、跳转路径、测试和页面文件名;确认无引用后才删除旧路径和旧资产。
6. 为 A01 生成并单独验收两张完整不透明按钮图片,替换现有 CSS 底色加四角 PNG 的过渡实现;不得裁切设计图,也不得改变当前画风。
7. 运行最小相关审计、编译检查、A01/G01 四尺寸截图核对和 `git diff --check`;视觉是否通过以用户换电脑后的设计图复核为准。
## 5. 当前停点(下次从这里继续)
本次已经完成且通过回归:
- 15 个已注册路由改为“页面编号-语义名”文件和 URL;旧入口已移除。
- 35 项运行时资产进入 `static/assets/foundation``static/assets/modules/genealogy`,并按 `opaque``transparent` 分类。
- 24 项经运行时引用扫描确认无引用的旧副本已删除。
- 页面和 3 个公共组件均补上中文用途/图层注释。
- 已通过 `foundation-structure-audit``foundation-asset-audit`、A01/A02、G01、核心流程、编译、清单、SCSS、Vue 入口以及 `git diff --check` 回归。
当前没有改动 A01、G01 的可见布局。A01 仍使用旧的“CSS 底色 + 四角 PNG”按钮过渡实现,因此不能称为地基最终验收完成。
为避免下次电脑丢失进度,已把两张未接入候选放进项目:
| 文件 | 来源与状态 | 下次处理 |
| --- | --- | --- |
| `static/assets/foundation/opaque/a01-primary-button-candidate.png` | 图像生成工具生成,1983×793,完整朱砂按钮皮肤;未接入 | 比例约 2.5:1,与现有按钮约 3.6:1 不一致;不能裁切设计图或硬拉伸,需按设计图重新生成/确认。 |
| `static/assets/foundation/opaque/a01-secondary-button-candidate.png` | 图像生成工具生成,1983×793,完整宣纸按钮皮肤;未接入 | 与主按钮同样先重做或确认比例、角纹、金边,再进入页面。 |
下次严格按此顺序继续:
1. 打开 A01 设计图和当前候选,先确认按钮的目标比例、边框粗细、四角纹样和红/宣纸颜色;候选不合格就重新生成,不接入。
2. 两张完整不透明按钮资产分别通过图片尺寸、完整四边框、无截图裁切和设计图人工比对后,才替换 A01 的 CSS 背景、边框及 `auth-button-corner.png` 四角节点。
3. 更新 A01 资产台账和最小测试;运行页面契约、资产审计、编译检查。
4. 在 320 × 568、360 × 640、360 × 800、412 × 915 截图;A01、G01 都须与设计图再次并排验收。用户可撤销此前“通过”,以新的设计图复核为准。
5. 用户确认 P-00 后,才开始 A02;后续每个模块先定完整资产包和母版,再批量实现页面,禁止边做边换画风。
## 6. 现有路由概况
当前路由为:
```text
pages/auth/a01-entry
pages/auth/a02-login
pages/genealogy/g01-my-genealogies
pages/genealogy/g03-create-genealogy
pages/genealogy/g05-genealogy-overview
pages/genealogy/g06-search-genealogies
pages/genealogy/g10-application-review
pages/genealogy/g04-first-ancestor
pages/skeleton/content-list-skeleton
pages/family/f02-publish-feed
pages/tree/t01-tree-overview
pages/tree/t03-member-profile
pages/family/f01-family-feed
pages/profile/m01-profile-home
pages/notification/n01-message-center
```
这些路径已完成当前 15 个路由的语义化迁移;逐项状态与资产台账见 P00 文档。不要新增无编号的 `index.vue``list.vue``detail.vue` 作为最终页面。
## 7. 运行与验证
- 现有测试位于 `tests/`;改动前后优先执行受影响页面的审计脚本。
- A01 相关:`tests/a01-a02-ui-contract.ps1``tests/a01-asset-alpha-audit.ps1``tests/a01-design-record-audit.ps1`
- 公共检查:`tests/foundation-structure-audit.ps1``tests/foundation-asset-audit.ps1``tests/compile-audit.ps1``tests/manifest-json.ps1``git diff --check`
- 截图验收尺寸:320 × 568、360 × 640、360 × 800、412 × 915A01、G01 必须至少复核 412 × 915。
## 8. 已知工作区注意事项
- 工作区可能带有用户未提交修改。只处理当前任务涉及文件;提交时只暂存本轮文件。
- 历史 `docs/superpowers/*a01*reference-asset-rebuild*` 与旧 G01 设计记录中存在已废弃的路径和“截图/切片式”尝试;不能作为当前实施依据。
- 当前依据永远以本文件、`docs/规划.md`、D1、P00 和用户最新消息为准;发生冲突时,以用户最新确认优先。
-152
View File
@@ -1,152 +0,0 @@
# 家谱 APP 总规划
> 电脑或 GPT 更换时,先阅读:[项目交接记录](交接记录.md)。
> 当前阶段:P-00 地基重整(结构与现有资产已迁移;A01 完整按钮候选已生成、待设计图复核与截图回归)
> 适用端:Android `uni-app`HBuilderX 打包)
> 页面总量:59 个规划页面/状态单元;当前已注册 15 个路由,两者不是同一口径。
## 1. 本轮目标与边界
- 只做 Android 用户端;不做 iOS、H5、PC 管理端、后台管理端或官网。
- 先完成项目地基:页面目录、路由映射、资产包、公共组件、中文注释和验收规则;地基通过前不继续 A02。
- 已由用户验收的 A01 启动/登录引导页与 G01 我的家谱页是**冻结视觉基准**。可重整其内部目录和复用方式,但 412 × 915 的可见效果不得改变。
- 现有的 15 个路由只是临时功能骨架;它们不等于 59 个页面均已设计或完成。
- 不新增业务接口、真实账号联调、Android 打包或设计清单以外的功能。
## 2. 唯一依据与完成口径
1. 本文件管理全项目范围、页面状态和推进顺序。
2. [D1 安卓视觉地基规范](design/D1_安卓视觉规范与页面壳.md)管理视觉、资产、组件和代码约束。
3. [P00 页面结构与资产清单](design/P00_页面结构与资产清单.md)管理路由命名、资产去留和清理结果。
4. [项目交接记录](交接记录.md)记录换电脑后应从哪里继续。
页面只有同时满足以下条件才可标记完成:
- 页面编号、功能、正常态和必要异常态明确。
- 使用已验收资产包,没有临时图片、截图切片、CSS 装饰或无引用资源。
- 在 320 × 568、360 × 640、360 × 800、412 × 915 下完成对应检查。
- 用户确认视觉结果;对应设计记录、规划和交接记录同步更新。
## 3. 已确认视觉地基
- 风格:家祠卷轴——暖宣纸、朱砂、古金、墨褐、淡墨山水;禁止绿色页面背景和“差不多的国风”。
- 冻结锚点:
- A01[412 × 915 栅格验收图](design/screens/A01-启动登录引导-栅格验收-412x915.png)
- G01[紧凑版设计稿](design/screens/G01-我的家谱-紧凑版设计稿-v5-Tabbar安全区.png)
- 视觉资产全部使用图片文件:不透明图片负责自身底色/纹理,透明 PNG 负责叠加装饰、Logo 与图标。CSS 只负责布局、文字和状态,不绘制可见装饰。
- 主按钮、次按钮、卡片、页头等有完整边框和底色的元素必须使用完整不透明图片;禁止四角、边线和 CSS 底色拼接。
- 不把整页设计图或其局部裁切后直接塞进页面;复杂背景使用完整背景图片,不能断裂或拼接。
## 4. 目录、资产与代码硬规则
### 4.1 页面与路由
- 页面文件使用“`页面编号-语义名.vue`”,例如 `a01-entry.vue``g01-my-genealogies.vue``t01-tree-overview.vue`
- `pages.json`、跳转 URL、测试与页面文件必须同轮更新;旧入口确认无引用后删除,不保留兼容副本。
- 当前路由到目标文件的映射只以 [P00 页面结构与资产清单](design/P00_页面结构与资产清单.md) 为准。
### 4.2 资产
- 资产目录按 `foundation|modules``opaque|transparent`、模块和语义名管理;不再使用含义不明的 `v1``v2``source`、重复文件名作为长期资产。
- 每项资产必须记录:路径、用途、所属组件、透明属性、显示尺寸、引用页面和去留状态。
- 删除前必须用代码引用扫描确认未被页面、组件、配置、测试或文档作为运行时资源引用;不能按文件名猜测。
- 所有新增可见资产先单独验收,再允许放入页面;资产未通过不能通过页面验收。
### 4.3 JavaScript、Vue 与中文注释
- 只实现已确认页面、交互和数据状态;不增加推测功能、预留接口、万能工具函数、无用变量或无关依赖。
- 组件只有在至少两个已确认页面共享同一结构和行为时才抽取;否则保留在页面内。
- 每个页面和组件文件开头注明编号/用途;模板按背景、页头、主体、操作和状态分段写中文注释。
- 非直观跳转、状态分支、资产图层和关键样式必须写中文注释,说明用途和约束;不为显而易见的单行属性堆无意义注释。
- 每轮改动后删除本轮产生的无用导入、变量、函数、路径和资产。
## 5. 59 个页面/状态单元
| 模块 | 范围 | 数量 | 当前状态 |
| --- | --- | ---: | --- |
| 账户与首次使用 | A-01 至 A-06 | 6 | A01 已验收;其余未完成 |
| 家谱 | G-01 至 G-12 | 12 | G01 已验收;其余未完成 |
| 世系树与成员 | T-01 至 T-08 | 8 | 未完成 |
| 家族内容 | F-01 至 F-10 | 10 | 未完成 |
| 人物、礼仪与档案 | R-01 至 R-11 | 11 | 未完成 |
| 消息 | N-01 至 N-02 | 2 | 未完成 |
| 个人中心 | M-01 至 M-10 | 10 | 未完成 |
| **合计** | | **59** | **已验收 2 个** |
### 5.1 账户与首次使用
- [x] A-01 启动/登录引导:登录、微信登录、注册入口、协议勾选。
- [ ] A-02 账号密码登录:账号密码/手机验证码双标签。
- [ ] A-03 手机验证码登录与微信授权回跳状态。
- [ ] A-04 注册与服务协议确认。
- [ ] A-05 忘记密码/重设密码。
- [ ] A-06 账号受限、登录失败、未完成注册、微信授权取消/失败。
### 5.2 家谱
- [x] G-01 我的家谱列表:已创建、已加入、通知入口。
- [ ] G-02 无家谱空状态。
- [ ] G-03 创建家谱表单。
- [ ] G-04 创建成功后的始祖录入。
- [ ] G-05 家谱总览。
- [ ] G-06 公开家谱搜索与筛选。
- [ ] G-07 搜索结果与家谱简介。
- [ ] G-08 申请加入、提交成功与审核中。
- [ ] G-09 我的申请记录。
- [ ] G-10 管理员申请审核与确认。
- [ ] G-11 家谱设置、公开范围与访问说明。
- [ ] G-12 字辈诗列表、详情与维护。
### 5.3 世系树与成员
- [ ] T-01 世系树总览。
- [ ] T-02 小屏提示、无成员与加载失败状态。
- [ ] T-03 成员档案详情。
- [ ] T-04 新增直系亲属。
- [ ] T-05 编辑成员资料。
- [ ] T-06 编辑亲属关系与冲突提示。
- [ ] T-07 成员目录与搜索。
- [ ] T-08 去世、隐私隐藏和无权限等状态。
### 5.4 家族内容
- [ ] F-01 家族首页/动态流。
- [ ] F-02 发布动态。
- [ ] F-03 动态详情、评论、点赞与删除确认。
- [ ] F-04 谱文分类与文章列表。
- [ ] F-05 谱文详情。
- [ ] F-06 新建/编辑谱文。
- [ ] F-07 相册列表。
- [ ] F-08 相册详情与照片墙。
- [ ] F-09 图片预览、上传与失败状态。
- [ ] F-10 视频列表、详情与接口未开放状态。
### 5.5 人物、礼仪与档案
- [ ] R-01 人物录列表;R-02 人物录详情与编辑。
- [ ] R-03 贺礼列表;R-04 贺礼新增、详情与删除确认。
- [ ] R-05 礼仪活动列表;R-06 礼仪详情;R-07 新建/编辑礼仪。
- [ ] R-08 成长日志;R-09 人生事;R-10 备忘录;R-11 功德记录。
### 5.6 消息与个人中心
- [ ] N-01 消息中心;N-02 消息详情、已读与空状态。
- [ ] M-01 我的首页;M-02 个人资料;M-03 安全设置;M-04 修改密码;M-05 修改手机号。
- [ ] M-06 帮助中心;M-07 意见反馈;M-08 应用推广;M-09 VIP 与订单;M-10 关于、协议、隐私与退出确认。
## 6. 推进顺序
1. **P-00 地基重整(当前)**:已完成页面映射、现有资产迁移、公共组件中文注释和运行时零引用旧资源清理;A01 两张完整按钮候选已保存但尚未接入。下次先按设计图复核候选比例和纹样,通过后才替换旧角标拼接实现,再回归 A01、G01。
2. A-02 至 A-06:在冻结的 A01 认证资产包上完成账户模块。
3. G-02 至 G-12:在冻结的 G01 列表、题签、导航资产包上完成家谱模块。
4. T、F、R、N、M 模块:每个模块先确认母版和完整资产包,再批量实现其页面/状态。
5. 全量四尺寸复核、真实接口接入与 Android 打包。
## 7. 本轮完成前的验收
- 页面路由、文件名与 P00 映射表一致;不存在不明 `index.vue``detail.vue``list.vue` 作为最终页面名。
- 所有保留资产都能说明用途;所有删除资产均通过未引用扫描。
- A01、G01 在 412 × 915 截图与当前用户验收基准无可见回退。
- 编译检查、页面/资产审计和 `git diff --check` 通过。
- 规划、交接、设计记录和资产清单同步反映真实状态。
+52
View File
@@ -0,0 +1,52 @@
# 项目交接说明
更新日期:2026-09-17
## 当前状态
这是一个 uni-app / Vue 3 家谱应用,现有 62 个页面。登录、家谱、成员树、家族内容、人物档案、消息和个人中心均已接入页面路由。
这次清理移除了此前留下的联调、审计、回归和页面对比记录,也删除了资源清单的两份单元测试。项目保留了运行时检查,当前工作区校验通过。
## 从哪里开始看
- `pages.json`:页面注册和页面级配置。
- `utils/navigation/routes.js`:业务跳转使用的路由定义。新增或删改页面时,这里要和 `pages.json` 一起改。
- `services/api/`:接口调用和接口数据整理。一般一个业务模块对应一组 `*-service.js``*-contract.js` 文件。
- `services/api/request-client.js`:请求入口,处理登录失效、响应格式和上传请求。
- `utils/runtime-config.js`:线上接口地址、客户端标识和租户标识。当前写的是正式环境配置。
- `static/assets/`:页面实际使用的图片资源;`design-pipeline/manifests/` 保存这些资源的校验清单。
## 常用检查
在根目录运行:
```bash
npm.cmd run check
```
PowerShell 的执行策略可能会拦截 `npm`,所以这里固定使用 `npm.cmd`。该命令目前会检查:
- 页面文件与路由注册是否匹配;
- 几处关键页面和导航恢复逻辑是否被误改;
- 静态资源文件是否和资源清单一致。
2026-09-17 的执行结果:
```text
PROJECT CHECK PASS pages=62 routes=62 sources=182
AUDIT REGRESSION CHECK PASS icons=17
NAVIGATION RECOVERY CHECK PASS
RUNTIME ASSET CHECK PASS manifests=4 assets=102
```
页面交互、上传或端能力相关的修改,仍需要在 HBuilderX 和目标端实际走一遍;这里的检查不能代替真机验证。
## 这次留下的改动
-`docs/` 中的 12 份过程记录已删除。
- `design-pipeline/tests/` 下的两份测试已删除,`design-pipeline``check` 现在只做资源清单校验。
- 根目录已移除 `check:parity`,因为它依赖已删掉的页面对比记录。
- `genealogy-app-openapi.yaml` 仍保留。它记录接口描述,改服务层前先核对它和现有 `services/api/` 实现是否一致。
提交前先看一次 `git status`。当前工作区包含上述删除和命令调整,确认无误后再提交。
File diff suppressed because it is too large Load Diff
+1
View File
@@ -3,6 +3,7 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" /> <meta name="viewport" content="width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
<link rel="icon" type="image/png" href="/static/assets/foundation/transparent/brand-seal.png" />
<title></title> <title></title>
<script type="module" src="/main.js"></script> <script type="module" src="/main.js"></script>
</head> </head>
+46 -7
View File
@@ -1,12 +1,17 @@
{ {
"name": "家谱", "name" : "代代相传家谱",
"appid": "__UNI__JIAPUAPP", "appid" : "__UNI__2E0520B",
"description" : "家祠卷轴风格的家谱共建应用", "description" : "家祠卷轴风格的家谱共建应用",
"versionName" : "1.0.0", "versionName" : "1.0.0",
"versionCode" : 100, "versionCode" : 100,
"transformPx" : false, "transformPx" : false,
"app-plus" : { "app-plus" : {
"usingComponents" : true, "usingComponents" : true,
"modules" : {
"Payment" : {},
"Share" : {},
"OAuth" : {}
},
"nvueStyleCompiler" : "uni-app", "nvueStyleCompiler" : "uni-app",
"compilerVersion" : 3, "compilerVersion" : 3,
"statusbar" : { "statusbar" : {
@@ -25,16 +30,50 @@
"permissions" : [ "permissions" : [
"<uses-permission android:name=\"android.permission.ACCESS_NETWORK_STATE\"/>", "<uses-permission android:name=\"android.permission.ACCESS_NETWORK_STATE\"/>",
"<uses-permission android:name=\"android.permission.INTERNET\"/>", "<uses-permission android:name=\"android.permission.INTERNET\"/>",
"<uses-permission android:name=\"android.permission.READ_MEDIA_IMAGES\"/>" "<uses-permission android:name=\"android.permission.READ_EXTERNAL_STORAGE\" android:maxSdkVersion=\"32\"/>",
"<uses-permission android:name=\"android.permission.READ_MEDIA_IMAGES\"/>",
"<uses-permission android:name=\"android.permission.READ_MEDIA_VIDEO\"/>"
] ]
}, },
"ios" : { "ios" : {
"dSYMs": false "dSYMs" : false,
} "privacyDescription" : {
"NSPhotoLibraryUsageDescription" : "用于选择并上传您主动添加的家谱头像、照片和视频"
} }
}, },
"mp-weixin": { "icons" : {
"usingComponents": true "android" : {
"hdpi" : "static/app-icons/72x72.png",
"xhdpi" : "static/app-icons/96x96.png",
"xxhdpi" : "static/app-icons/144x144.png",
"xxxhdpi" : "static/app-icons/192x192.png"
},
"ios" : {
"appstore" : "static/app-icons/1024x1024.png",
"ipad" : {
"app" : "static/app-icons/76x76.png",
"app@2x" : "static/app-icons/152x152.png",
"notification" : "static/app-icons/20x20.png",
"notification@2x" : "static/app-icons/40x40.png",
"proapp@2x" : "static/app-icons/167x167.png",
"settings" : "static/app-icons/29x29.png",
"settings@2x" : "static/app-icons/58x58.png",
"spotlight" : "static/app-icons/40x40.png",
"spotlight@2x" : "static/app-icons/80x80.png"
},
"iphone" : {
"app@2x" : "static/app-icons/120x120.png",
"app@3x" : "static/app-icons/180x180.png",
"notification@2x" : "static/app-icons/40x40.png",
"notification@3x" : "static/app-icons/60x60.png",
"settings@2x" : "static/app-icons/58x58.png",
"settings@3x" : "static/app-icons/87x87.png",
"spotlight@2x" : "static/app-icons/80x80.png",
"spotlight@3x" : "static/app-icons/120x120.png"
}
}
}
}
}, },
"vueVersion" : "3" "vueVersion" : "3"
} }

Some files were not shown because too many files have changed in this diff Show More