Compare commits
52 Commits
ff60119277
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| f53a2dbc87 | |||
| bbc024f7f2 | |||
| 9ad572907b | |||
| c59e36f933 | |||
| ab90ab488b | |||
| 5dabcd175e | |||
| 964262f693 | |||
| 8207965754 | |||
| cc706378c2 | |||
| 555aa00043 | |||
| 96524e4d6c | |||
| 8c2355a52c | |||
| 6fbcf21024 | |||
| 1e9e25fe67 | |||
| 04287e6777 | |||
| 1eae3bbef4 | |||
| 8475bbd19a | |||
| ec8486b035 | |||
| c7f278fe79 | |||
| bb6431b319 | |||
| f1edc6b533 | |||
| 9b0ad62df4 | |||
| eced3d1e6e | |||
| a127280ce6 | |||
| 5d99bae3f2 | |||
| a0d19f4aab | |||
| 9362c47b95 | |||
| 2f22df61b3 | |||
| 779a44279c | |||
| 0542fa9102 | |||
| 668b4ef55f | |||
| 8468cc0f89 | |||
| ec1295de0e | |||
| cd3dbb2ab1 | |||
| b774ce8212 | |||
| 75c8e4fcbb | |||
| a28678ca0b | |||
| 5a86803f9b | |||
| 40947a1fe5 | |||
| 8f88d6ecdf | |||
| c0f3233e26 | |||
| 9c7ceb9a02 | |||
| daccf7ba3d | |||
| bc4061c8dc | |||
| c5af1f2414 | |||
| 119d820308 | |||
| 15cce0aba8 | |||
| 086fc3c7e4 | |||
| 47afb100cd | |||
| 69592868d0 | |||
| 2d33ee71e4 | |||
| 4b338ebefc |
+6
-2
@@ -57,7 +57,11 @@ sitemap.xml
|
||||
/tmp/
|
||||
/unpackage/
|
||||
/.vite/
|
||||
/.audit/
|
||||
/design-pipeline/generated/
|
||||
/docs/design/screens/runtime/
|
||||
/docs/superpowers/specs/design/
|
||||
/tmp-g01-icon-audit.png
|
||||
/tmp-mumu-current.png
|
||||
/artifacts/
|
||||
/docs/audit-*/
|
||||
/docs/audits/
|
||||
/book-style-*.png
|
||||
|
||||
-5287
File diff suppressed because it is too large
Load Diff
@@ -1,11 +1,8 @@
|
||||
<script>
|
||||
export default {
|
||||
onLaunch() {
|
||||
console.log('家谱 App 已启动')
|
||||
}
|
||||
}
|
||||
export default {}
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
// Global styles own the fixed-header host stacking contract.
|
||||
@import './styles/global.scss';
|
||||
</style>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -3,7 +3,11 @@
|
||||
class="app-button"
|
||||
:class="[
|
||||
`app-button--${type}`,
|
||||
{ 'app-button--block': block, 'app-button--disabled': disabled },
|
||||
{
|
||||
'app-button--block': block,
|
||||
'app-button--compact': compact,
|
||||
'app-button--disabled': disabled,
|
||||
},
|
||||
]"
|
||||
:disabled="disabled"
|
||||
:aria-label="label"
|
||||
@@ -11,7 +15,7 @@
|
||||
@click="handleClick"
|
||||
>
|
||||
<image class="app-button__skin" :src="skin" mode="aspectFit" />
|
||||
<text class="app-button__label"
|
||||
<text class="app-button__label app-single-line"
|
||||
><slot>{{ label }}</slot></text
|
||||
>
|
||||
</button>
|
||||
@@ -24,14 +28,15 @@ 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/a01-scroll-secondary-v3.png"
|
||||
: "/static/assets/foundation/transparent/a01-scroll-primary-v3.png",
|
||||
? "/static/assets/foundation/transparent/scroll-secondary.png"
|
||||
: "/static/assets/foundation/transparent/scroll-primary.png",
|
||||
);
|
||||
|
||||
const handleClick = (event) => {
|
||||
@@ -40,6 +45,8 @@ const handleClick = (event) => {
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use "../styles/adaptive-frame-profiles.scss" as adaptive;
|
||||
|
||||
.app-button {
|
||||
display: inline-grid;
|
||||
width: 420rpx;
|
||||
@@ -60,6 +67,23 @@ const handleClick = (event) => {
|
||||
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%;
|
||||
@@ -71,11 +95,13 @@ const handleClick = (event) => {
|
||||
}
|
||||
.app-button__label {
|
||||
z-index: 1;
|
||||
padding: 0 38rpx;
|
||||
padding: 12rpx 38rpx;
|
||||
box-sizing: border-box;
|
||||
color: #fffaf0;
|
||||
font-size: 29rpx;
|
||||
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 {
|
||||
|
||||
+77
-17
@@ -12,12 +12,15 @@
|
||||
:aria-label="title"
|
||||
tabindex="-1"
|
||||
@click.stop
|
||||
@keydown.esc.stop="cancel"
|
||||
@keydown.esc.stop.prevent="cancel"
|
||||
@keydown.tab="trapFocus"
|
||||
>
|
||||
<scroll-view class="app-dialog__content" scroll-y>
|
||||
<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 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"
|
||||
@@ -27,9 +30,14 @@
|
||||
v-if="showCancel"
|
||||
type="secondary"
|
||||
:label="cancelText"
|
||||
:compact="compactActions && showCancel"
|
||||
@click="cancel"
|
||||
/>
|
||||
<AppButton :label="confirmText" @click="$emit('confirm')" />
|
||||
<AppButton
|
||||
:label="confirmText"
|
||||
:compact="compactActions && showCancel"
|
||||
@click="$emit('confirm')"
|
||||
/>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
@@ -48,12 +56,45 @@ const props = defineProps({
|
||||
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) => {
|
||||
@@ -61,7 +102,7 @@ watch(
|
||||
if (visible) {
|
||||
previousFocus = document.activeElement;
|
||||
await nextTick();
|
||||
dialogRef.value?.focus?.();
|
||||
getDialogElement()?.focus?.();
|
||||
return;
|
||||
}
|
||||
previousFocus?.focus?.();
|
||||
@@ -75,6 +116,8 @@ const cancel = () => {
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use "../styles/adaptive-frame-profiles.scss" as adaptive;
|
||||
|
||||
.app-dialog-layer {
|
||||
position: fixed;
|
||||
z-index: 80;
|
||||
@@ -83,37 +126,50 @@ const cancel = () => {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
padding: 40rpx;
|
||||
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%;
|
||||
min-height: 520rpx;
|
||||
background: url("/static/assets/modules/auth/transparent/a01-scroll-dialog-v3.png")
|
||||
center / contain no-repeat;
|
||||
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: 520rpx;
|
||||
max-height: calc(100vh - 80rpx);
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
box-sizing: border-box;
|
||||
padding: 74rpx 66rpx 54rpx;
|
||||
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: 22rpx;
|
||||
font-size: clamp(14px, 22rpx, 17px);
|
||||
font-weight: 700;
|
||||
letter-spacing: 4rpx;
|
||||
}
|
||||
.app-dialog__title {
|
||||
color: #8f160f;
|
||||
font-size: 42rpx;
|
||||
font-size: clamp(22px, 42rpx, 28px);
|
||||
font-weight: 700;
|
||||
letter-spacing: 4rpx;
|
||||
}
|
||||
@@ -123,7 +179,7 @@ const cancel = () => {
|
||||
.app-dialog__message {
|
||||
margin-top: 22rpx;
|
||||
color: #513a28;
|
||||
font-size: 27rpx;
|
||||
font-size: clamp(16px, 27rpx, 20px);
|
||||
line-height: 1.65;
|
||||
}
|
||||
.app-dialog__actions {
|
||||
@@ -131,7 +187,11 @@ const cancel = () => {
|
||||
width: 100%;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 16rpx;
|
||||
margin-top: auto;
|
||||
margin-top: 26rpx;
|
||||
}
|
||||
.app-dialog__actions :deep(.app-button__label) {
|
||||
padding-right: 18rpx;
|
||||
padding-left: 18rpx;
|
||||
}
|
||||
.app-dialog__actions--single {
|
||||
display: flex;
|
||||
|
||||
@@ -82,11 +82,11 @@ defineProps({
|
||||
}
|
||||
.app-loading--page .app-loading__copy {
|
||||
margin-top: 22rpx;
|
||||
font-size: 30rpx;
|
||||
font-size: clamp(17px, 30rpx, 22px);
|
||||
}
|
||||
.app-loading--page .app-loading__description {
|
||||
margin-top: 14rpx;
|
||||
font-size: 24rpx;
|
||||
font-size: clamp(15px, 24rpx, 18px);
|
||||
}
|
||||
|
||||
.app-loading--section {
|
||||
@@ -103,11 +103,11 @@ defineProps({
|
||||
}
|
||||
.app-loading--section .app-loading__copy {
|
||||
margin-top: 14rpx;
|
||||
font-size: 24rpx;
|
||||
font-size: clamp(15px, 24rpx, 18px);
|
||||
}
|
||||
.app-loading--section .app-loading__description {
|
||||
margin-top: 10rpx;
|
||||
font-size: 22rpx;
|
||||
font-size: clamp(14px, 22rpx, 17px);
|
||||
}
|
||||
@keyframes app-loading-seal-breathe {
|
||||
0%,
|
||||
|
||||
@@ -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>
|
||||
+49
-15
@@ -1,54 +1,69 @@
|
||||
<!-- 公共组件:根页面底部导航;仅维护家谱、家族、我的三项已确认入口及其透明图标。 -->
|
||||
<template>
|
||||
<view class="app-tabbar">
|
||||
<view
|
||||
<view
|
||||
class="app-tabbar"
|
||||
:class="{ 'app-tabbar--light': tone === 'light' }"
|
||||
role="tablist"
|
||||
aria-label="主要导航"
|
||||
>
|
||||
<button
|
||||
v-for="item in items"
|
||||
:key="item.key"
|
||||
class="tab-item"
|
||||
@click="switchTab(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', { active: active === item.key }]">{{
|
||||
<text :class="['tab-label', 'app-single-line', { active: active === item.key }]">{{
|
||||
item.label
|
||||
}}</text>
|
||||
</view>
|
||||
</button>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<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 = [
|
||||
{
|
||||
key: "genealogy",
|
||||
label: "家谱",
|
||||
path: "/pages/genealogy/g01-my-genealogies",
|
||||
routeKey: "G01",
|
||||
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",
|
||||
label: "消息",
|
||||
routeKey: "F01",
|
||||
icon: "/static/assets/foundation/transparent/tab-family.png",
|
||||
activeIcon: "/static/assets/foundation/transparent/tab-family-active.png",
|
||||
},
|
||||
{
|
||||
key: "profile",
|
||||
label: "我的",
|
||||
path: "/pages/profile/m01-profile-home",
|
||||
routeKey: "M01",
|
||||
icon: "/static/assets/foundation/transparent/tab-profile.png",
|
||||
activeIcon: "/static/assets/foundation/transparent/tab-profile-active.png",
|
||||
},
|
||||
];
|
||||
|
||||
const switchTab = (item) => {
|
||||
if (item.key !== props.active) uni.reLaunch({ url: item.path });
|
||||
const switchRoot = (item) => {
|
||||
if (item.key === props.active) return;
|
||||
return goRoot(item.routeKey);
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -60,22 +75,39 @@ const switchTab = (item) => {
|
||||
left: 0;
|
||||
z-index: 20;
|
||||
display: flex;
|
||||
height: 112rpx;
|
||||
min-height: 112rpx;
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
box-sizing: content-box;
|
||||
border-top: 1rpx solid $gold-soft;
|
||||
background: $paper-white;
|
||||
}
|
||||
|
||||
.app-tabbar--light {
|
||||
border-top-color: #e5dac4;
|
||||
background: #fffdf9;
|
||||
}
|
||||
|
||||
.tab-item {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
min-height: 88rpx;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: 0;
|
||||
padding: 4rpx 0;
|
||||
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 {
|
||||
@@ -85,10 +117,12 @@ const switchTab = (item) => {
|
||||
}
|
||||
|
||||
.tab-label {
|
||||
max-width: 100%;
|
||||
color: $ink-muted;
|
||||
font-size: 30rpx;
|
||||
font-size: clamp(17px, 30rpx, 22px);
|
||||
letter-spacing: 2rpx;
|
||||
line-height: 1.2;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.tab-label.active {
|
||||
|
||||
@@ -18,6 +18,8 @@ defineProps({
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use "../styles/adaptive-frame-profiles.scss" as adaptive;
|
||||
|
||||
.app-toast {
|
||||
position: fixed;
|
||||
z-index: 100;
|
||||
@@ -29,13 +31,7 @@ defineProps({
|
||||
min-height: 82rpx;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
border: 16rpx solid transparent;
|
||||
border-width: 16rpx 74rpx;
|
||||
border-image-source: url("/static/assets/foundation/transparent/a01-scroll-toast-v3.png");
|
||||
border-image-slice: 58 280 fill;
|
||||
border-image-width: 16rpx 74rpx;
|
||||
border-image-repeat: stretch;
|
||||
@include adaptive.adaptive-feedback-toast;
|
||||
transform: translateX(-50%);
|
||||
pointer-events: none;
|
||||
}
|
||||
@@ -43,7 +39,7 @@ defineProps({
|
||||
z-index: 1;
|
||||
padding: 14rpx 30rpx;
|
||||
color: #5c4330;
|
||||
font-size: 25rpx;
|
||||
font-size: clamp(15px, 25rpx, 18px);
|
||||
font-weight: 500;
|
||||
line-height: 1.45;
|
||||
text-align: center;
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -1,275 +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-updated">
|
||||
<text>更新于 {{ genealogy.updatedAt }}</text>
|
||||
</view>
|
||||
<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>
|
||||
</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 {
|
||||
--card-padding-x: 24rpx;
|
||||
--card-padding-y: 18rpx;
|
||||
display: grid;
|
||||
grid-template-columns: 72rpx minmax(0, 1fr);
|
||||
min-height: 178rpx;
|
||||
align-items: center;
|
||||
column-gap: 22rpx;
|
||||
padding: 18rpx 24rpx;
|
||||
box-sizing: border-box;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.genealogy-card--current {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.surname-seal {
|
||||
display: grid;
|
||||
grid-column: 1;
|
||||
grid-row: 1;
|
||||
z-index: 2;
|
||||
width: 72rpx;
|
||||
height: 112rpx;
|
||||
place-items: center;
|
||||
color: #fff5df;
|
||||
}
|
||||
|
||||
.surname-seal-frame {
|
||||
z-index: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
.surname-seal-frame,
|
||||
.surname-seal-copy {
|
||||
grid-area: 1 / 1;
|
||||
}
|
||||
.surname-seal-copy {
|
||||
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 {
|
||||
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: flex-start;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.card-name {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
color: $ink;
|
||||
font-family: "STKaiti", "KaiTi", serif;
|
||||
font-size: 37rpx;
|
||||
font-weight: 700;
|
||||
line-height: 1.25;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.card-detail-row {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-top: 8rpx;
|
||||
}
|
||||
.card-metas {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
margin-top: 2rpx;
|
||||
gap: 4rpx 18rpx;
|
||||
}
|
||||
.card-meta-item {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
margin-right: 0;
|
||||
}
|
||||
.card-meta-icon {
|
||||
width: 46rpx;
|
||||
height: 46rpx;
|
||||
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: 26rpx;
|
||||
font-weight: 500;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.card-updated {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
margin-top: 0;
|
||||
margin-left: 0;
|
||||
color: #62584c;
|
||||
font-size: 24rpx;
|
||||
font-weight: 500;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.card-side {
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex: 0 1 auto;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
margin-left: 20rpx;
|
||||
}
|
||||
|
||||
.card-role {
|
||||
min-width: 0;
|
||||
color: #62584c;
|
||||
font-size: 26rpx;
|
||||
font-weight: 500;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.card-chevron {
|
||||
width: 34rpx;
|
||||
height: 34rpx;
|
||||
margin-left: 14rpx;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 340px) {
|
||||
.genealogy-card {
|
||||
--card-padding-x: 18rpx;
|
||||
grid-template-columns: 66rpx minmax(0, 1fr);
|
||||
column-gap: 16rpx;
|
||||
min-height: 148rpx;
|
||||
padding-right: 18rpx;
|
||||
padding-left: 18rpx;
|
||||
}
|
||||
.surname-seal {
|
||||
width: 66rpx;
|
||||
height: 100rpx;
|
||||
}
|
||||
.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>
|
||||
@@ -1,514 +0,0 @@
|
||||
<!-- 公共组件:F/R/N/M 普通任务页母版;业务结构按类型区分,视觉继承各模块基准页。 -->
|
||||
<template>
|
||||
<view
|
||||
class="module-page"
|
||||
:style="moduleAssetStyle"
|
||||
:class="[
|
||||
`module-page--${moduleKey}`,
|
||||
`module-page--${page.template}`,
|
||||
`module-state--${moduleState}`,
|
||||
]"
|
||||
>
|
||||
<ModulePageBackground :module="moduleKey" />
|
||||
<view class="module-page__header"><PageHeader :title="page.title" /></view>
|
||||
|
||||
<view v-if="moduleState === 'loading'" class="module-page__loading">
|
||||
<AppLoading
|
||||
:text="`正在整理${page.title}`"
|
||||
description="请稍候,内容正在归卷。"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<view v-else-if="moduleState === 'ready'" class="module-page__content">
|
||||
<view class="module-lead">
|
||||
<text>{{ page.subtitle }}</text>
|
||||
</view>
|
||||
|
||||
<view v-if="page.template === 'form'" class="module-panel module-form">
|
||||
<text class="section-eyebrow">填写信息</text>
|
||||
<view v-for="field in page.fields" :key="field" class="form-row">
|
||||
<text>{{ field }}</text>
|
||||
<input
|
||||
:placeholder="`请输入${field}`"
|
||||
placeholder-class="form-placeholder"
|
||||
/>
|
||||
</view>
|
||||
<text v-if="page.note" class="form-note">{{ normalizedNote }}</text>
|
||||
<AppButton block :label="page.action" @click="completeAction" />
|
||||
</view>
|
||||
|
||||
<view v-else-if="page.template === 'list'" class="content-stack">
|
||||
<view
|
||||
v-for="item in page.sections"
|
||||
:key="item[0]"
|
||||
class="list-card"
|
||||
@click="openPreview(item)"
|
||||
>
|
||||
<view class="list-card__copy">
|
||||
<text>{{ item[0] }}</text>
|
||||
<text>{{ item[1] }}</text>
|
||||
<text>查看详情</text>
|
||||
</view>
|
||||
</view>
|
||||
<AppButton block :label="page.action" @click="completeAction" />
|
||||
</view>
|
||||
|
||||
<view
|
||||
v-else-if="page.template === 'detail'"
|
||||
class="module-panel module-detail"
|
||||
>
|
||||
<text class="section-eyebrow">档案详情</text>
|
||||
<view v-for="item in page.sections" :key="item[0]" class="detail-card">
|
||||
<view
|
||||
><text>{{ item[0] }}</text
|
||||
><text>{{ item[1] }}</text></view
|
||||
>
|
||||
</view>
|
||||
<AppButton block :label="page.action" @click="completeAction" />
|
||||
</view>
|
||||
|
||||
<view
|
||||
v-else-if="page.template === 'timeline'"
|
||||
class="content-stack timeline-stack"
|
||||
>
|
||||
<view
|
||||
v-for="(item, index) in page.sections"
|
||||
:key="item[0]"
|
||||
class="timeline-row"
|
||||
>
|
||||
<view
|
||||
><text>第 {{ index + 1 }} 则</text><text>{{ item[0] }}</text
|
||||
><text>{{ item[1] }}</text></view
|
||||
>
|
||||
</view>
|
||||
<AppButton block :label="page.action" @click="completeAction" />
|
||||
</view>
|
||||
|
||||
<view
|
||||
v-else-if="page.template === 'settings'"
|
||||
class="module-panel module-settings"
|
||||
>
|
||||
<text class="section-eyebrow">服务与设置</text>
|
||||
<view
|
||||
v-for="item in page.sections"
|
||||
:key="item[0]"
|
||||
class="settings-row"
|
||||
@click="openPreview(item)"
|
||||
>
|
||||
<view
|
||||
><text>{{ item[0] }}</text
|
||||
><text>{{ normalizedSectionCopy(item[1]) }}</text></view
|
||||
>
|
||||
<text>查看</text>
|
||||
</view>
|
||||
<AppButton block :label="page.action" @click="completeAction" />
|
||||
</view>
|
||||
|
||||
<view v-else class="module-panel status-card">
|
||||
<view class="status-card__body">
|
||||
<text class="status-card__eyebrow">{{ page.badge }} · 服务说明</text>
|
||||
<text class="status-card__lead">{{ page.lead }}</text>
|
||||
<text class="status-card__note">{{ normalizedNote }}</text>
|
||||
<AppButton block :label="page.action" @click="completeAction" />
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-else class="module-page__content module-page__content--state">
|
||||
<view class="module-panel status-card">
|
||||
<view class="status-card__body">
|
||||
<text class="status-card__eyebrow">{{ stateCopy.eyebrow }}</text>
|
||||
<text class="status-card__lead">{{ stateCopy.title }}</text>
|
||||
<text class="status-card__note">{{ stateCopy.copy }}</text>
|
||||
<AppButton
|
||||
block
|
||||
:type="moduleState === 'error' ? 'secondary' : 'primary'"
|
||||
:label="stateCopy.action"
|
||||
@click="handleStateAction"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<AppTabbar v-if="page.tab" :active="page.tab" />
|
||||
<AppToast :visible="toastVisible" :message="toastMessage" />
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onUnmounted, ref } from "vue";
|
||||
import AppButton from "@/components/AppButton.vue";
|
||||
import AppLoading from "@/components/AppLoading.vue";
|
||||
import PageHeader from "@/components/PageHeader.vue";
|
||||
import AppTabbar from "@/components/AppTabbar.vue";
|
||||
import AppToast from "@/components/AppToast.vue";
|
||||
import ModulePageBackground from "@/components/ModulePageBackground.vue";
|
||||
import { pageCatalog } from "@/data/page-catalog.js";
|
||||
|
||||
const props = defineProps({ pageId: { type: String, required: true } });
|
||||
const page = computed(() => pageCatalog[props.pageId]);
|
||||
const moduleKey = computed(
|
||||
() =>
|
||||
({ f: "family", r: "records", n: "notification", m: "profile" })[
|
||||
props.pageId[0]
|
||||
] || "profile",
|
||||
);
|
||||
const assetModule = computed(
|
||||
() =>
|
||||
({
|
||||
family: "family",
|
||||
records: "records",
|
||||
notification: "notification",
|
||||
profile: "profile",
|
||||
})[moduleKey.value],
|
||||
);
|
||||
const contentAsset = computed(
|
||||
() =>
|
||||
`/static/assets/modules/${assetModule.value}/transparent/module-content-frame.png`,
|
||||
);
|
||||
const fieldAsset = computed(
|
||||
() =>
|
||||
`/static/assets/modules/${assetModule.value}/transparent/module-field-frame.png`,
|
||||
);
|
||||
const moduleAssetStyle = computed(() => ({
|
||||
"--module-content-asset": `url(${contentAsset.value})`,
|
||||
"--module-field-asset": `url(${fieldAsset.value})`,
|
||||
}));
|
||||
const query = (() => {
|
||||
if (typeof location !== "undefined")
|
||||
return Object.fromEntries(
|
||||
new URLSearchParams(location.hash.split("?")[1] || ""),
|
||||
);
|
||||
const pages = getCurrentPages();
|
||||
return pages[pages.length - 1]?.options || {};
|
||||
})();
|
||||
const moduleState = ref(
|
||||
["loading", "empty", "success", "error"].includes(query.state)
|
||||
? query.state
|
||||
: "ready",
|
||||
);
|
||||
const toastVisible = ref(false);
|
||||
const toastMessage = ref("");
|
||||
let toastTimer = null;
|
||||
const normalizedNote = computed(() =>
|
||||
(page.value.note || "").replace(
|
||||
/待接入|服务待接入|功能待接入/g,
|
||||
"将在后续功能阶段开放",
|
||||
),
|
||||
);
|
||||
const normalizedSectionCopy = (copy) =>
|
||||
String(copy || "").replace(/待接入|功能待接入/g, "后续功能阶段开放");
|
||||
const stateCopy = computed(() => {
|
||||
const pageState = page.value.states?.[moduleState.value];
|
||||
if (pageState) return pageState;
|
||||
return (
|
||||
{
|
||||
empty: {
|
||||
eyebrow: "暂无内容",
|
||||
title: `还没有${page.value.title}记录`,
|
||||
copy: "完成第一条记录后,内容会按时间或类别整理在这里。",
|
||||
action: page.value.action,
|
||||
},
|
||||
success: {
|
||||
eyebrow: "操作结果",
|
||||
title: `${page.value.title}内容已更新`,
|
||||
copy: "当前页面已展示操作结果;正式数据将在接口阶段接入。",
|
||||
action: "继续查看",
|
||||
},
|
||||
error: {
|
||||
eyebrow: "内容暂不可用",
|
||||
title: `暂时无法打开${page.value.title}`,
|
||||
copy: "请稍后重新进入,现有资料不会受到影响。",
|
||||
action: "重新查看",
|
||||
},
|
||||
}[moduleState.value] || {}
|
||||
);
|
||||
});
|
||||
const handleStateAction = () => {
|
||||
if (stateCopy.value.actionMode === "back") {
|
||||
uni.navigateBack();
|
||||
return;
|
||||
}
|
||||
moduleState.value = "ready";
|
||||
};
|
||||
const completeAction = () => {
|
||||
moduleState.value = "success";
|
||||
};
|
||||
const openPreview = (item) => {
|
||||
toastMessage.value = item[0];
|
||||
toastVisible.value = true;
|
||||
if (toastTimer) clearTimeout(toastTimer);
|
||||
toastTimer = setTimeout(() => {
|
||||
toastVisible.value = false;
|
||||
toastTimer = null;
|
||||
}, 1800);
|
||||
};
|
||||
onUnmounted(() => {
|
||||
if (toastTimer) clearTimeout(toastTimer);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.module-page {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
flex-direction: column;
|
||||
background: $paper;
|
||||
}
|
||||
.module-page__header,
|
||||
.module-page__content,
|
||||
.module-page__loading {
|
||||
z-index: 1;
|
||||
}
|
||||
.module-page__loading {
|
||||
min-height: calc(100vh - 100rpx);
|
||||
}
|
||||
.module-page__content {
|
||||
padding: 16rpx 26rpx 56rpx;
|
||||
}
|
||||
.module-lead {
|
||||
min-height: 56rpx;
|
||||
margin: 0 12rpx 18rpx;
|
||||
color: $ink-muted;
|
||||
font-size: 23rpx;
|
||||
text-align: center;
|
||||
background: url("/static/assets/modules/genealogy/transparent/section-divider.png") center / 100% 100% no-repeat;
|
||||
}
|
||||
.module-lead text {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
}
|
||||
.module-panel {
|
||||
width: 100%;
|
||||
}
|
||||
.section-eyebrow {
|
||||
display: block;
|
||||
margin: 2rpx 8rpx 14rpx;
|
||||
color: $brand-red;
|
||||
font-family: STKaiti, KaiTi, serif;
|
||||
font-size: 29rpx;
|
||||
font-weight: 700;
|
||||
letter-spacing: 3rpx;
|
||||
}
|
||||
.form-row {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
min-height: 84rpx;
|
||||
align-items: center;
|
||||
gap: 22rpx;
|
||||
margin-top: 14rpx;
|
||||
padding: 14rpx 26rpx;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.form-row,
|
||||
.settings-row {
|
||||
background: var(--module-field-asset) center / 100% 100% no-repeat;
|
||||
}
|
||||
.form-row > text {
|
||||
color: $ink;
|
||||
font-size: 24rpx;
|
||||
font-weight: 700;
|
||||
}
|
||||
.form-row input {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
min-height: 56rpx;
|
||||
color: $ink;
|
||||
font-size: 23rpx;
|
||||
text-align: right;
|
||||
}
|
||||
.form-placeholder {
|
||||
color: #9e8e79;
|
||||
}
|
||||
.form-note {
|
||||
display: block;
|
||||
margin: 18rpx 18rpx 0;
|
||||
color: $ink-muted;
|
||||
font-size: 22rpx;
|
||||
line-height: 1.5;
|
||||
text-align: center;
|
||||
}
|
||||
.module-form > .app-button,
|
||||
.module-detail > .app-button,
|
||||
.module-settings > .app-button,
|
||||
.content-stack > .app-button {
|
||||
margin-top: 22rpx;
|
||||
}
|
||||
.content-stack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16rpx;
|
||||
}
|
||||
.list-card,
|
||||
.timeline-row {
|
||||
width: 100%;
|
||||
min-height: 188rpx;
|
||||
}
|
||||
.list-card,
|
||||
.detail-card,
|
||||
.timeline-row,
|
||||
.status-card {
|
||||
background: var(--module-content-asset) center / 100% 100% no-repeat;
|
||||
}
|
||||
.list-card__copy {
|
||||
padding: 34rpx 46rpx 28rpx;
|
||||
}
|
||||
.list-card__copy text {
|
||||
display: block;
|
||||
}
|
||||
.list-card__copy text:first-child {
|
||||
color: $ink;
|
||||
font-family: STKaiti, KaiTi, serif;
|
||||
font-size: 30rpx;
|
||||
font-weight: 700;
|
||||
}
|
||||
.list-card__copy text:nth-child(2) {
|
||||
margin-top: 10rpx;
|
||||
color: $ink-muted;
|
||||
font-size: 23rpx;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.list-card__copy text:last-child {
|
||||
margin-top: 11rpx;
|
||||
color: $brand-red;
|
||||
font-size: 21rpx;
|
||||
font-weight: 600;
|
||||
}
|
||||
.detail-card {
|
||||
min-height: 170rpx;
|
||||
margin-top: 16rpx;
|
||||
}
|
||||
.detail-card > view {
|
||||
padding: 34rpx 44rpx;
|
||||
}
|
||||
.detail-card text,
|
||||
.settings-row text {
|
||||
display: block;
|
||||
}
|
||||
.detail-card text:first-child {
|
||||
color: $brand-red;
|
||||
font-size: 23rpx;
|
||||
font-weight: 700;
|
||||
}
|
||||
.detail-card text:last-child {
|
||||
margin-top: 10rpx;
|
||||
color: $ink;
|
||||
font-size: 24rpx;
|
||||
line-height: 1.55;
|
||||
}
|
||||
.timeline-row > view {
|
||||
padding: 29rpx 44rpx;
|
||||
}
|
||||
.timeline-row text {
|
||||
display: block;
|
||||
}
|
||||
.timeline-row text:first-child {
|
||||
color: $brand-red;
|
||||
font-size: 21rpx;
|
||||
letter-spacing: 2rpx;
|
||||
}
|
||||
.timeline-row text:nth-child(2) {
|
||||
margin-top: 6rpx;
|
||||
color: $ink;
|
||||
font-family: STKaiti, KaiTi, serif;
|
||||
font-size: 30rpx;
|
||||
font-weight: 700;
|
||||
}
|
||||
.timeline-row text:last-child {
|
||||
margin-top: 9rpx;
|
||||
color: $ink-muted;
|
||||
font-size: 23rpx;
|
||||
}
|
||||
.settings-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
min-height: 94rpx;
|
||||
align-items: center;
|
||||
gap: 20rpx;
|
||||
margin-top: 14rpx;
|
||||
padding: 20rpx 28rpx 18rpx 26rpx;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.settings-row > view text:first-child {
|
||||
color: $ink;
|
||||
font-size: 24rpx;
|
||||
font-weight: 700;
|
||||
}
|
||||
.settings-row > view text:last-child {
|
||||
margin-top: 6rpx;
|
||||
color: $ink-muted;
|
||||
font-size: 21rpx;
|
||||
}
|
||||
.settings-row > text {
|
||||
color: $brand-red;
|
||||
font-size: 21rpx;
|
||||
font-weight: 600;
|
||||
}
|
||||
.status-card {
|
||||
min-height: 330rpx;
|
||||
text-align: center;
|
||||
}
|
||||
.status-card__body {
|
||||
padding: 70rpx 60rpx 48rpx;
|
||||
}
|
||||
.status-card__eyebrow {
|
||||
display: block;
|
||||
color: $brand-red;
|
||||
font-size: 23rpx;
|
||||
letter-spacing: 3rpx;
|
||||
}
|
||||
.status-card__lead {
|
||||
display: block;
|
||||
margin-top: 14rpx;
|
||||
color: $ink;
|
||||
font-family: STKaiti, KaiTi, serif;
|
||||
font-size: 35rpx;
|
||||
font-weight: 700;
|
||||
}
|
||||
.status-card__note {
|
||||
display: block;
|
||||
margin-top: 17rpx;
|
||||
color: $ink-muted;
|
||||
font-size: 24rpx;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.status-card .app-button {
|
||||
margin: 28rpx auto 0;
|
||||
}
|
||||
.module-page__content--state {
|
||||
padding-top: 72rpx;
|
||||
}
|
||||
.module-page--family .section-eyebrow {
|
||||
color: #9f2c23;
|
||||
}
|
||||
.module-page--records .section-eyebrow {
|
||||
color: #8c261f;
|
||||
}
|
||||
.module-page--notification .section-eyebrow {
|
||||
color: #9b2b21;
|
||||
}
|
||||
.module-page--profile .section-eyebrow {
|
||||
color: #a22d24;
|
||||
}
|
||||
@media (min-width: 400px) {
|
||||
.module-page__content {
|
||||
padding-right: 34rpx;
|
||||
padding-left: 34rpx;
|
||||
}
|
||||
}
|
||||
@media (max-width: 340px) {
|
||||
.module-page__content {
|
||||
padding-right: 20rpx;
|
||||
padding-left: 20rpx;
|
||||
}
|
||||
.form-row {
|
||||
gap: 14rpx;
|
||||
}
|
||||
.status-card__body {
|
||||
padding-right: 44rpx;
|
||||
padding-left: 44rpx;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -7,7 +7,7 @@
|
||||
<image
|
||||
class="module-page-background__image"
|
||||
:src="source"
|
||||
mode="widthFix"
|
||||
mode="aspectFill"
|
||||
/>
|
||||
</view>
|
||||
</template>
|
||||
@@ -48,6 +48,7 @@ const source = computed(() => sources[props.module] || sources.profile);
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
opacity: 0.36;
|
||||
}
|
||||
.module-page-background--family .module-page-background__image {
|
||||
|
||||
+127
-35
@@ -1,4 +1,3 @@
|
||||
<!-- 公共组件:根页朱砂页头与普通返回页头;根页背景图层由统一基础资产提供。 -->
|
||||
<template>
|
||||
<view class="page-header-slot" :class="{ 'page-header-slot--root': root }">
|
||||
<view class="page-header" :class="{ 'page-header--root': root }">
|
||||
@@ -8,92 +7,123 @@
|
||||
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">
|
||||
<button
|
||||
<view
|
||||
v-if="root"
|
||||
class="header-icon-button"
|
||||
aria-label="返回首页"
|
||||
@click="$emit('brand')"
|
||||
class="header-brand"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<image
|
||||
class="header-logo"
|
||||
src="/static/assets/foundation/transparent/brand-seal.png"
|
||||
mode="aspectFit"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
</view>
|
||||
<button
|
||||
v-else
|
||||
class="header-back"
|
||||
aria-label="返回上一页"
|
||||
hover-class="header-back--pressed"
|
||||
@click="goBack"
|
||||
@click="handleBack"
|
||||
>
|
||||
<image
|
||||
class="header-back__icon"
|
||||
src="/static/assets/foundation/transparent/chevron-right.png"
|
||||
mode="aspectFit"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
</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">
|
||||
<button
|
||||
v-if="root"
|
||||
v-if="root && notice"
|
||||
class="header-icon-button header-notice"
|
||||
aria-label="打开消息中心"
|
||||
: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>
|
||||
</button>
|
||||
<button v-else class="header-action" :disabled="!action" @click="$emit('action')">{{
|
||||
<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>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { goBack } from "@/utils/navigation/gateway.js";
|
||||
|
||||
const props = defineProps({
|
||||
title: { type: String, required: true },
|
||||
action: { type: String, default: "" },
|
||||
root: { type: Boolean, default: false },
|
||||
unreadCount: { type: Number, default: 0 },
|
||||
fallbackUrl: { type: String, default: "/pages/genealogy/g01-my-genealogies" },
|
||||
notice: { type: Boolean, default: false },
|
||||
customBack: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
const goBack = () => {
|
||||
const stack = getCurrentPages();
|
||||
if (stack.length > 1) {
|
||||
uni.navigateBack();
|
||||
const emit = defineEmits(["notice", "action", "back"]);
|
||||
|
||||
const handleBack = () => {
|
||||
if (props.customBack) {
|
||||
emit("back");
|
||||
return;
|
||||
}
|
||||
uni.reLaunch({ url: props.fallbackUrl });
|
||||
return goBack();
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.page-header-slot {
|
||||
height: 104rpx;
|
||||
height: calc(104rpx + var(--status-bar-height, 0px));
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.page-header-slot--root {
|
||||
height: calc(124rpx + var(--status-bar-height, 0px));
|
||||
height: calc(88rpx + var(--status-bar-height, 0px));
|
||||
}
|
||||
|
||||
.page-header {
|
||||
@@ -104,15 +134,17 @@ const goBack = () => {
|
||||
z-index: 30;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 104rpx;
|
||||
height: calc(104rpx + var(--status-bar-height, 0px));
|
||||
padding: 0 24rpx;
|
||||
padding-top: var(--status-bar-height, 0px);
|
||||
box-sizing: border-box;
|
||||
background: $brand-red;
|
||||
color: #fff9ed;
|
||||
transform: translateZ(0);
|
||||
}
|
||||
|
||||
.page-header--root {
|
||||
height: calc(124rpx + var(--status-bar-height, 0px));
|
||||
height: calc(88rpx + var(--status-bar-height, 0px));
|
||||
padding-top: var(--status-bar-height, 0px);
|
||||
background-color: #b52e22;
|
||||
overflow: hidden;
|
||||
@@ -135,8 +167,8 @@ const goBack = () => {
|
||||
bottom: -2rpx;
|
||||
left: -48rpx;
|
||||
z-index: 1;
|
||||
width: 476rpx;
|
||||
height: 166rpx;
|
||||
width: 408rpx;
|
||||
height: 126rpx;
|
||||
opacity: 0.29;
|
||||
pointer-events: none;
|
||||
}
|
||||
@@ -148,6 +180,7 @@ const goBack = () => {
|
||||
|
||||
.header-side {
|
||||
display: flex;
|
||||
flex: 0 0 120rpx;
|
||||
width: 120rpx;
|
||||
min-height: 88rpx;
|
||||
align-items: center;
|
||||
@@ -159,6 +192,10 @@ const goBack = () => {
|
||||
.header-side--right {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.page-header:not(.page-header--root) .header-side--right {
|
||||
flex-basis: 152rpx;
|
||||
width: 152rpx;
|
||||
}
|
||||
|
||||
.header-icon-button {
|
||||
display: grid;
|
||||
@@ -171,6 +208,17 @@ const goBack = () => {
|
||||
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 {
|
||||
@@ -205,13 +253,30 @@ const goBack = () => {
|
||||
}
|
||||
|
||||
.header-action {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
min-height: 88rpx;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
font-size: 27rpx;
|
||||
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 {
|
||||
@@ -235,17 +300,45 @@ const goBack = () => {
|
||||
.header-back--pressed {
|
||||
opacity: 0.62;
|
||||
}
|
||||
.header-action {
|
||||
color: #ffe3a7;
|
||||
text-align: right;
|
||||
.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 {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
color: #fff9ed;
|
||||
font-family: serif;
|
||||
font-size: 35rpx;
|
||||
font-size: clamp(19px, 35rpx, 24px);
|
||||
font-weight: 700;
|
||||
letter-spacing: 4rpx;
|
||||
text-align: center;
|
||||
@@ -256,18 +349,17 @@ const goBack = () => {
|
||||
.page-header--root .header-title {
|
||||
color: #ffe3a7;
|
||||
font-family: "STKaiti", "KaiTi", serif;
|
||||
font-size: 48rpx;
|
||||
letter-spacing: 5rpx;
|
||||
transform: translateY(2rpx);
|
||||
font-size: clamp(21px, 40rpx, 25px);
|
||||
letter-spacing: 4rpx;
|
||||
}
|
||||
|
||||
.page-header--root .header-logo {
|
||||
width: 80rpx;
|
||||
height: 80rpx;
|
||||
transform: translate(-6rpx, -6rpx);
|
||||
width: 64rpx;
|
||||
height: 64rpx;
|
||||
transform: translate(-2rpx, -2rpx);
|
||||
}
|
||||
|
||||
.page-header--root .header-notice {
|
||||
transform: translateY(4rpx);
|
||||
transform: none;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -1,13 +1,22 @@
|
||||
<template>
|
||||
<view class="genealogy-page-background">
|
||||
<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="widthFix"
|
||||
mode="aspectFill"
|
||||
/>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
tone: { type: String, default: "default" },
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.genealogy-page-background {
|
||||
position: fixed;
|
||||
@@ -25,6 +34,16 @@
|
||||
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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
@@ -1,335 +0,0 @@
|
||||
<!-- T04-T06 共用成员表单:仅复用视觉与交互骨架,各页面通过 kind 拥有独立任务和文案。 -->
|
||||
<template>
|
||||
<view
|
||||
class="member-form-page"
|
||||
:class="{
|
||||
'form-state--form': formState === 'form',
|
||||
'form-state--success': formState === 'success',
|
||||
'form-state--conflict': formState === 'conflict',
|
||||
'form-state--error': formState === 'error',
|
||||
}"
|
||||
>
|
||||
<ModulePageBackground module="tree" />
|
||||
<view class="member-form-page__header"
|
||||
><PageHeader :title="config.pageTitle"
|
||||
/></view>
|
||||
<view class="member-form-panel">
|
||||
<view v-if="formState === 'form'" class="member-form">
|
||||
<text class="member-form__eyebrow">{{ config.eyebrow }}</text>
|
||||
<text class="member-form__title">{{ config.title }}</text>
|
||||
<text class="member-form__copy">{{ config.copy }}</text>
|
||||
<view
|
||||
v-for="field in config.fields"
|
||||
:key="field.key"
|
||||
class="member-field"
|
||||
>
|
||||
<text>{{ field.label }}</text>
|
||||
<input
|
||||
v-model="form[field.key]"
|
||||
:placeholder="field.placeholder"
|
||||
placeholder-class="member-placeholder"
|
||||
/>
|
||||
</view>
|
||||
<text class="member-form__note">{{ config.note }}</text>
|
||||
<view class="member-form-action" @click="saveForm">
|
||||
<image
|
||||
src="/static/assets/foundation/transparent/a01-scroll-primary-v3.png"
|
||||
mode="aspectFit"
|
||||
/><text>{{ config.action }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-else class="member-form-result">
|
||||
<text class="member-form__eyebrow">{{ resultCopy.eyebrow }}</text>
|
||||
<text class="member-form__title">{{ resultCopy.title }}</text>
|
||||
<text class="member-form__copy">{{ resultCopy.copy }}</text>
|
||||
<view v-if="formState === 'conflict'" class="conflict-actions">
|
||||
<view class="member-form-action" @click="formState = 'form'">
|
||||
<image
|
||||
src="/static/assets/foundation/transparent/a01-scroll-secondary-v3.png"
|
||||
mode="aspectFit"
|
||||
/><text class="member-form-action__secondary">返回核对</text>
|
||||
</view>
|
||||
<view class="member-form-action" @click="showConflictHelp">
|
||||
<image
|
||||
src="/static/assets/foundation/transparent/a01-scroll-primary-v3.png"
|
||||
mode="aspectFit"
|
||||
/><text>查看冲突</text>
|
||||
</view>
|
||||
</view>
|
||||
<view v-else class="member-form-action" @click="formState = 'form'">
|
||||
<image
|
||||
src="/static/assets/foundation/transparent/a01-scroll-primary-v3.png"
|
||||
mode="aspectFit"
|
||||
/><text>{{ formState === "success" ? "继续完善" : "重新填写" }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<AppDialog
|
||||
:visible="conflictHelpVisible"
|
||||
eyebrow="关系校验规则"
|
||||
title="关系冲突说明"
|
||||
message="同一成员不能同时存在两个生父,也不能形成上下代循环。请返回世系树核对原关系后再调整。"
|
||||
@confirm="conflictHelpVisible = false"
|
||||
@close="conflictHelpVisible = false"
|
||||
/>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, reactive, ref } from "vue";
|
||||
import AppDialog from "@/components/AppDialog.vue";
|
||||
import ModulePageBackground from "@/components/ModulePageBackground.vue";
|
||||
import PageHeader from "@/components/PageHeader.vue";
|
||||
|
||||
const props = defineProps({ kind: { type: String, required: true } });
|
||||
const configs = {
|
||||
add: {
|
||||
pageTitle: "新增亲属",
|
||||
eyebrow: "补全家族关系",
|
||||
title: "为汤文远添加一位亲属",
|
||||
copy: "先确认与当前成员的关系,再录入基础身份信息。",
|
||||
action: "保存亲属",
|
||||
note: "保存后将在世系图中显示,可继续补充详细档案。",
|
||||
fields: [
|
||||
{ key: "name", label: "姓名", placeholder: "请输入真实姓名" },
|
||||
{ key: "relation", label: "与本人关系", placeholder: "例如:长子、配偶" },
|
||||
{ key: "birthDate", label: "出生日期", placeholder: "例如:1992年" },
|
||||
{ key: "gender", label: "性别", placeholder: "请选择或输入" },
|
||||
],
|
||||
},
|
||||
edit: {
|
||||
pageTitle: "编辑成员",
|
||||
eyebrow: "成员档案维护",
|
||||
title: "完善汤文远的生命记录",
|
||||
copy: "基础身份用于世系展示,生平信息可在成员档案中继续补充。",
|
||||
action: "保存资料",
|
||||
note: "隐私字段只向本人和有权限的家谱管理员展示。",
|
||||
fields: [
|
||||
{ key: "name", label: "姓名", placeholder: "汤文远" },
|
||||
{ key: "generation", label: "字辈", placeholder: "例如:文" },
|
||||
{ key: "birthDate", label: "出生日期", placeholder: "1940年" },
|
||||
{ key: "summary", label: "人物简介", placeholder: "简要记录生平" },
|
||||
],
|
||||
},
|
||||
relation: {
|
||||
pageTitle: "关系维护",
|
||||
eyebrow: "亲属关系校正",
|
||||
title: "确认两位成员的家族关系",
|
||||
copy: "调整关系会影响世系位置,保存前请核对成员和关系类型。",
|
||||
action: "保存关系",
|
||||
note: "若形成重复父母、代际倒置等情况,页面会先提示冲突。",
|
||||
fields: [
|
||||
{ key: "source", label: "当前成员", placeholder: "汤文远" },
|
||||
{ key: "target", label: "关联成员", placeholder: "请选择家谱成员" },
|
||||
{ key: "relation", label: "关系类型", placeholder: "父子、配偶等" },
|
||||
{ key: "effectiveDate", label: "生效日期", placeholder: "选填" },
|
||||
],
|
||||
},
|
||||
};
|
||||
const config = computed(() => configs[props.kind]);
|
||||
const form = reactive({
|
||||
name: "",
|
||||
relation: "",
|
||||
birthDate: "",
|
||||
gender: "",
|
||||
generation: "",
|
||||
summary: "",
|
||||
source: "",
|
||||
target: "",
|
||||
effectiveDate: "",
|
||||
});
|
||||
const query = (() => {
|
||||
if (typeof location !== "undefined")
|
||||
return Object.fromEntries(
|
||||
new URLSearchParams(location.hash.split("?")[1] || ""),
|
||||
);
|
||||
const pages = getCurrentPages();
|
||||
return pages[pages.length - 1]?.options || {};
|
||||
})();
|
||||
const formState = ref(
|
||||
query.state === "success"
|
||||
? "success"
|
||||
: query.state === "conflict"
|
||||
? "conflict"
|
||||
: query.state === "error"
|
||||
? "error"
|
||||
: "form",
|
||||
);
|
||||
const conflictHelpVisible = ref(false);
|
||||
const resultCopy = computed(
|
||||
() =>
|
||||
({
|
||||
success: {
|
||||
eyebrow: "本地预览已更新",
|
||||
title: `${config.value.pageTitle}内容已保存`,
|
||||
copy: "返回世系树后可查看新的展示位置;正式数据将在接口阶段接入。",
|
||||
},
|
||||
conflict: {
|
||||
eyebrow: "发现关系冲突",
|
||||
title: "这段关系会造成代际矛盾",
|
||||
copy: "目标成员已经存在父级关系,请先核对原关系,再决定是否调整。",
|
||||
},
|
||||
error: {
|
||||
eyebrow: "内容未保存",
|
||||
title: "暂时无法完成这次操作",
|
||||
copy: "请返回成员档案重新进入,当前填写内容不会写入正式数据。",
|
||||
},
|
||||
})[formState.value] || {},
|
||||
);
|
||||
|
||||
const saveForm = () => {
|
||||
if (
|
||||
props.kind === "relation" &&
|
||||
(!form.target.trim() || !form.relation.trim())
|
||||
) {
|
||||
formState.value = "conflict";
|
||||
return;
|
||||
}
|
||||
formState.value = "success";
|
||||
};
|
||||
const showConflictHelp = () => {
|
||||
conflictHelpVisible.value = true;
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.member-form-page {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
flex-direction: column;
|
||||
background: $paper;
|
||||
}
|
||||
.member-form-page__header {
|
||||
z-index: 3;
|
||||
}
|
||||
.member-form-panel {
|
||||
z-index: 2;
|
||||
width: calc(100% - 32rpx);
|
||||
min-height: min(640px, calc((100vw - 16px) * 1.48));
|
||||
margin: 18rpx auto 0;
|
||||
padding: 7.5% 8%;
|
||||
box-sizing: border-box;
|
||||
background: url("/static/assets/modules/tree/transparent/t01-state-panel.png")
|
||||
center / 100% 100% no-repeat;
|
||||
}
|
||||
.member-form,
|
||||
.member-form-result {
|
||||
z-index: 1;
|
||||
}
|
||||
.member-form__eyebrow {
|
||||
display: block;
|
||||
color: $brand-red;
|
||||
font-size: 22rpx;
|
||||
letter-spacing: 3rpx;
|
||||
}
|
||||
.member-form__title {
|
||||
display: block;
|
||||
margin-top: 10rpx;
|
||||
color: $ink;
|
||||
font-family: "STKaiti", "KaiTi", serif;
|
||||
font-size: 34rpx;
|
||||
font-weight: 700;
|
||||
}
|
||||
.member-form__copy {
|
||||
display: block;
|
||||
margin-top: 9rpx;
|
||||
color: $ink-muted;
|
||||
font-size: 24rpx;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.member-field {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
min-height: 78rpx;
|
||||
align-items: center;
|
||||
gap: 20rpx;
|
||||
margin-top: 12rpx;
|
||||
padding: 12rpx 22rpx;
|
||||
box-sizing: border-box;
|
||||
background: url("/static/assets/modules/tree/transparent/t07-search-input-frame.png")
|
||||
center / 100% 100% no-repeat;
|
||||
}
|
||||
.member-field > text {
|
||||
z-index: 1;
|
||||
color: $ink;
|
||||
font-size: 24rpx;
|
||||
font-weight: 700;
|
||||
}
|
||||
.member-field input {
|
||||
z-index: 1;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
min-height: 54rpx;
|
||||
color: $ink;
|
||||
font-size: 24rpx;
|
||||
text-align: right;
|
||||
}
|
||||
.member-placeholder {
|
||||
color: #a79884;
|
||||
}
|
||||
.member-form__note {
|
||||
display: block;
|
||||
margin-top: 14rpx;
|
||||
color: $ink-muted;
|
||||
font-size: 22rpx;
|
||||
line-height: 1.45;
|
||||
text-align: center;
|
||||
}
|
||||
.member-form-action {
|
||||
display: grid;
|
||||
width: 100%;
|
||||
min-height: 76rpx;
|
||||
margin-top: 17rpx;
|
||||
}
|
||||
.member-form-action image {
|
||||
grid-area: 1 / 1;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
}
|
||||
.member-form-action text {
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
grid-area: 1 / 1;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
color: #fff9ed;
|
||||
font-size: 24rpx;
|
||||
font-weight: 700;
|
||||
}
|
||||
.member-form-action .member-form-action__secondary {
|
||||
color: $ink;
|
||||
}
|
||||
.member-form-result {
|
||||
margin-top: 21.5%;
|
||||
text-align: center;
|
||||
}
|
||||
.member-form-result .member-form__eyebrow {
|
||||
text-align: center;
|
||||
}
|
||||
.member-form-result .member-form__copy {
|
||||
margin-top: 20rpx;
|
||||
}
|
||||
.member-form-result > .member-form-action {
|
||||
width: 420rpx;
|
||||
max-width: 100%;
|
||||
margin: 32rpx auto 0;
|
||||
}
|
||||
.conflict-actions {
|
||||
display: flex;
|
||||
gap: 14rpx;
|
||||
margin-top: 30rpx;
|
||||
}
|
||||
.conflict-actions .member-form-action {
|
||||
width: calc(50% - 7rpx);
|
||||
margin-top: 0;
|
||||
}
|
||||
@media (min-width: 400px) {
|
||||
.member-form-panel {
|
||||
width: calc(100% - 48rpx);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -1,70 +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: '仅成员可见',
|
||||
membership: 'created',
|
||||
motto: '敦亲睦族,敬祖传家。'
|
||||
},
|
||||
{
|
||||
id: 1002,
|
||||
surname: '汤',
|
||||
name: '汤氏宗谱',
|
||||
hall: '承志堂',
|
||||
location: '山东·济宁',
|
||||
memberCount: 286,
|
||||
updatedAt: '2024-04-28',
|
||||
activeCount: 15,
|
||||
visibility: '公开可申请',
|
||||
membership: 'joined',
|
||||
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' }
|
||||
]
|
||||
@@ -1,46 +0,0 @@
|
||||
// 全量视觉交付的本地页面目录:只保存页面内容与状态,样式由 ModulePage 的六类母版统一维护。
|
||||
export const pageCatalog = {
|
||||
|
||||
f03: { number: 'F-03', title: '动态详情', subtitle: '一段属于家人的共同记忆', template: 'detail', sections: [['端午家宴', '今年端午全家相聚,留下了许多温暖照片。'], ['家人评论', '愿家族和睦兴旺,岁岁平安。']], action: '写下评论' },
|
||||
f04: { number: 'F-04', title: '谱文', subtitle: '收录家族文章、家训与往事', template: 'list', sections: [['家风家训', '代代相传的处世之道'], ['家族往事', '珍贵的口述与文字记录'], ['族谱序言', '一部家谱的起源']], action: '新建谱文' },
|
||||
f05: {
|
||||
number: 'F-05',
|
||||
title: '谱文详情',
|
||||
subtitle: '家族文字档案',
|
||||
template: 'detail',
|
||||
sections: [['家风家训', '孝友传家,勤俭立业;敬祖睦宗,诚实待人。'], ['收录时间', '2024 年 5 月 12 日']],
|
||||
action: '收藏谱文',
|
||||
states: {
|
||||
empty: {
|
||||
eyebrow: '谱文已失效',
|
||||
title: '这篇谱文已无法查看',
|
||||
copy: '内容可能已被作者删除或取消公开,请返回谱文列表查看其他内容。',
|
||||
action: '返回谱文列表',
|
||||
actionMode: 'back',
|
||||
},
|
||||
},
|
||||
},
|
||||
f07: { number: 'F-07', title: '家族相册', subtitle: '让每一张照片都回到家人身边', template: 'list', sections: [['2024 春节团圆', '18 张照片 · 更新于今天'], ['祖居旧影', '32 张照片 · 家族档案'], ['儿童成长', '46 张照片 · 持续更新']], action: '新建相册' },
|
||||
r01: { number: 'R-01', title: '人物录', subtitle: '记录家族中值得铭记的人', template: 'list', sections: [['汤文正', '家谱管理员 · 第 18 世'], ['汤淑华', '家族长辈 · 第 17 世'], ['汤文清', '青年代表 · 第 19 世']], action: '新建人物' },
|
||||
r03: { number: 'R-03', title: '贺礼簿', subtitle: '记录每一份家人之间的心意', template: 'list', sections: [['新春贺礼', '2024 年春节 · 12 条记录'], ['寿宴礼单', '汤老先生八十寿辰'], ['添丁祝福', '家族新成员的祝愿']], action: '新增贺礼' },
|
||||
r04: { number: 'R-04', title: '编辑贺礼', subtitle: '如实留下礼仪往来记录', template: 'form', fields: ['贺礼名称', '赠送人', '日期', '备注'], action: '保存贺礼', note: '删除确认功能待接入' },
|
||||
r05: { number: 'R-05', title: '礼仪活动', subtitle: '家族的重要时刻与礼序', template: 'list', sections: [['清明祭祖', '2024 年 4 月 4 日'], ['中秋家宴', '2024 年 9 月 17 日'], ['新春团拜', '2025 年 1 月 29 日']], action: '新建礼仪' },
|
||||
r06: { number: 'R-06', title: '礼仪详情', subtitle: '清明祭祖', template: 'detail', sections: [['活动说明', '缅怀先祖,凝聚家人,共叙家风传承。'], ['参与人员', '已登记 26 位家人']], action: '编辑活动' },
|
||||
r07: { number: 'R-07', title: '编辑礼仪', subtitle: '补全时间、地点与活动说明', template: 'form', fields: ['活动名称', '活动日期', '举办地点', '活动说明'], action: '保存活动', note: '发布提醒功能待接入' },
|
||||
r08: { number: 'R-08', title: '成长日志', subtitle: '珍藏生命成长中的每一个瞬间', template: 'timeline', sections: [['第一次叫爸爸', '2024 年 3 月 · 家人共同记录'], ['入园第一天', '2024 年 9 月 · 留下勇敢的笑脸']], action: '记录成长' },
|
||||
r09: { number: 'R-09', title: '人生事', subtitle: '记录值得回望的人生节点', template: 'timeline', sections: [['大学毕业', '2018 年 6 月 · 新的起点'], ['结为连理', '2022 年 10 月 · 家人见证']], action: '新增人生事' },
|
||||
r10: { number: 'R-10', title: '家族备忘', subtitle: '把重要的事留给未来的自己', template: 'list', sections: [['修谱资料整理', '请在本月底前补充老照片信息'], ['重阳敬老活动', '10 月 11 日上午在祠堂集合']], action: '新增备忘' },
|
||||
r11: { number: 'R-11', title: '功德记录', subtitle: '致谢每一份对家族的守护', template: 'list', sections: [['修缮祠堂', '2024 年春 · 家族共同支持'], ['奖学助学', '2024 年夏 · 鼓励后辈勤学']], action: '新增记录' },
|
||||
|
||||
n02: { number: 'N-02', title: '消息详情', subtitle: '家谱提醒', template: 'detail', sections: [['申请待审核', '汤文清申请加入家谱,请在审核页面查看。'], ['收到时间', '今天 10:28']], action: '标记已读' },
|
||||
|
||||
m02: { number: 'M-02', title: '个人资料', subtitle: '完善你的家谱身份信息', template: 'form', fields: ['头像昵称', '真实姓名', '常住地区', '个人简介'], action: '保存资料', note: '头像上传服务待接入' },
|
||||
m03: { number: 'M-03', title: '账号与安全', subtitle: '保护你的家谱账号', template: 'settings', sections: [['登录密码', '建议定期更新'], ['绑定手机号', '用于登录和安全验证'], ['登录设备', '暂无异常设备']], action: '检查账号安全' },
|
||||
m04: { number: 'M-04', title: '修改密码', subtitle: '设置新的安全密码', template: 'form', fields: ['当前密码', '新密码', '确认新密码'], action: '确认修改', note: '密码服务待接入' },
|
||||
m05: { number: 'M-05', title: '修改手机号', subtitle: '更换用于登录的手机号', template: 'form', fields: ['当前手机号', '新手机号', '验证码'], action: '确认更换', note: '短信验证服务待接入' },
|
||||
m06: { number: 'M-06', title: '帮助中心', subtitle: '常见问题与使用说明', template: 'settings', sections: [['如何创建家谱', '从家谱首页点击新建家谱'], ['如何邀请家人', '通过申请审核或邀请码加入'], ['资料隐私说明', '个人信息按权限展示']], action: '联系家谱助手' },
|
||||
m07: { number: 'M-07', title: '意见反馈', subtitle: '你的建议会让家谱更好', template: 'form', fields: ['反馈类型', '问题描述', '联系方式'], action: '提交反馈', note: '反馈服务待接入' },
|
||||
m08: { number: 'M-08', title: '应用推广', subtitle: '邀请亲友共建家族记忆', template: 'detail', sections: [['邀请家人', '将家谱分享给家人,共同补全家族故事。'], ['推广说明', '邀请功能与奖励规则待接入。']], action: '生成邀请海报' },
|
||||
m09: { number: 'M-09', title: 'VIP 与订单', subtitle: '家谱服务与订阅记录', template: 'status', badge: '礼', lead: '当前没有订阅订单', note: 'VIP 服务开放后会在这里展示权益与订单', action: '查看服务说明' },
|
||||
m10: { number: 'M-10', title: '关于家谱', subtitle: '传承每一段值得珍藏的家族记忆', template: 'settings', sections: [['用户协议', '功能待接入'], ['隐私政策', '功能待接入'], ['当前版本', '1.0.0'], ['退出登录', '仅本地演示']], action: '查看版本说明' }
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
{
|
||||
"schemaVersion": 2,
|
||||
"page": "A01",
|
||||
"runtime": {
|
||||
"url": "http://localhost:5173/#/pages/auth/a01-entry",
|
||||
"chromePort": 9222
|
||||
},
|
||||
"assets": [
|
||||
{
|
||||
"id": "a01-primary-button-v2",
|
||||
"assetClass": "fixed-bitmap",
|
||||
"source": "static/assets/foundation/opaque/a01-primary-button.png",
|
||||
"output": "static/assets/foundation/transparent/a01-primary-button-v2.png",
|
||||
"logicalSlot": { "widthRpx": 622, "heightRpx": 92 },
|
||||
"outputPixels": { "width": 1866, "height": 276 },
|
||||
"render": { "scalePolicy": "uniform-only", "uniMode": "aspectFit", "allowDistortion": false },
|
||||
"alpha": { "required": true, "transparentOuterPadding": 12, "cornerMaxAlpha": 0 },
|
||||
"edge": { "forbidChromaResidue": true, "forbidLightFringe": true, "premultipliedAlphaCheck": true },
|
||||
"quality": { "maxBytes": 1000000, "symmetry": "horizontal", "colorSpace": "sRGB" },
|
||||
"processing": { "trim": 8, "capWidth": 180 },
|
||||
"runtime": { "selector": ".login-submit", "imageSelector": ".button-skin img" },
|
||||
"consumers": ["pages/auth/a01-entry.vue"]
|
||||
},
|
||||
{
|
||||
"id": "a01-secondary-button-v2",
|
||||
"assetClass": "fixed-bitmap",
|
||||
"source": "static/assets/foundation/opaque/a01-secondary-button.png",
|
||||
"output": "static/assets/foundation/transparent/a01-secondary-button-v2.png",
|
||||
"logicalSlot": { "widthRpx": 622, "heightRpx": 100 },
|
||||
"outputPixels": { "width": 1866, "height": 300 },
|
||||
"render": { "scalePolicy": "uniform-only", "uniMode": "aspectFit", "allowDistortion": false },
|
||||
"alpha": { "required": true, "transparentOuterPadding": 12, "cornerMaxAlpha": 0 },
|
||||
"edge": { "forbidChromaResidue": true, "forbidLightFringe": true, "premultipliedAlphaCheck": true },
|
||||
"quality": { "maxBytes": 1200000, "symmetry": "horizontal", "colorSpace": "sRGB" },
|
||||
"processing": { "trim": 12, "capWidth": 200 },
|
||||
"runtime": { "selector": ".wechat-login", "imageSelector": ".button-skin img" },
|
||||
"consumers": ["pages/auth/a01-entry.vue"]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
{
|
||||
"schemaVersion": 2,
|
||||
"page": "A01",
|
||||
"runtime": {
|
||||
"url": "http://localhost:5173/#/pages/auth/a01-entry",
|
||||
"chromePort": 9222
|
||||
},
|
||||
"assets": [
|
||||
{
|
||||
"id": "a01-scroll-primary-v3",
|
||||
"assetClass": "fixed-bitmap",
|
||||
"source": "docs/design/assets/a01-vnext/masters/a01-scroll-primary-master-v3.png",
|
||||
"output": "static/assets/foundation/transparent/a01-scroll-primary-v3.png",
|
||||
"logicalSlot": { "widthRpx": 622, "heightRpx": 92 },
|
||||
"outputPixels": { "width": 1866, "height": 276 },
|
||||
"render": { "scalePolicy": "uniform-only", "uniMode": "aspectFit", "allowDistortion": false },
|
||||
"alpha": { "required": true, "transparentOuterPadding": 6, "cornerMaxAlpha": 0 },
|
||||
"edge": { "forbidChromaResidue": true, "forbidLightFringe": true, "premultipliedAlphaCheck": true },
|
||||
"quality": { "maxBytes": 1800000, "symmetry": "horizontal", "colorSpace": "sRGB" },
|
||||
"processing": { "trim": 1, "capWidth": 380, "keyColor": "#00FF00", "keyTolerance": 96 },
|
||||
"runtime": { "selector": ".login-submit", "imageSelector": ".button-skin img" },
|
||||
"consumers": ["pages/auth/a01-entry.vue"]
|
||||
},
|
||||
{
|
||||
"id": "a01-scroll-secondary-v3",
|
||||
"assetClass": "fixed-bitmap",
|
||||
"source": "docs/design/assets/a01-vnext/masters/a01-scroll-secondary-master-v3.png",
|
||||
"output": "static/assets/foundation/transparent/a01-scroll-secondary-v3.png",
|
||||
"logicalSlot": { "widthRpx": 622, "heightRpx": 100 },
|
||||
"outputPixels": { "width": 1866, "height": 300 },
|
||||
"render": { "scalePolicy": "uniform-only", "uniMode": "aspectFit", "allowDistortion": false },
|
||||
"alpha": { "required": true, "transparentOuterPadding": 6, "cornerMaxAlpha": 0 },
|
||||
"edge": { "forbidChromaResidue": true, "forbidLightFringe": true, "premultipliedAlphaCheck": true },
|
||||
"quality": { "maxBytes": 1800000, "symmetry": "horizontal", "colorSpace": "sRGB" },
|
||||
"processing": { "trim": 1, "capWidth": 380, "keyColor": "#00FF00", "keyTolerance": 96 },
|
||||
"runtime": { "selector": ".wechat-login", "imageSelector": ".button-skin img" },
|
||||
"consumers": ["pages/auth/a01-entry.vue"]
|
||||
},
|
||||
{
|
||||
"id": "a01-scroll-toast-v3",
|
||||
"assetClass": "nine-slice",
|
||||
"source": "docs/design/assets/a01-vnext/masters/a01-scroll-toast-master-v3.png",
|
||||
"output": "static/assets/foundation/transparent/a01-scroll-toast-v3.png",
|
||||
"logicalSlot": { "widthRpx": 590, "heightRpx": 82 },
|
||||
"outputPixels": { "width": 1770, "height": 246 },
|
||||
"render": { "scalePolicy": "nine-slice", "uniMode": "aspectFit", "allowDistortion": false },
|
||||
"alpha": { "required": true, "transparentOuterPadding": 6, "cornerMaxAlpha": 0 },
|
||||
"edge": { "forbidChromaResidue": true, "forbidLightFringe": true, "premultipliedAlphaCheck": true },
|
||||
"quality": { "maxBytes": 1600000, "symmetry": "horizontal", "colorSpace": "sRGB" },
|
||||
"processing": { "trim": 1, "capWidth": 340, "keyColor": "#00FF00", "keyTolerance": 96 },
|
||||
"runtime": { "selector": ".feedback-toast", "imageSelector": ".feedback-toast__skin img" },
|
||||
"consumers": ["pages/auth/a01-entry.vue"]
|
||||
},
|
||||
{
|
||||
"id": "a01-scroll-dialog-v3",
|
||||
"assetClass": "fixed-bitmap",
|
||||
"source": "docs/design/assets/a01-vnext/masters/a01-scroll-dialog-master-v3-r2.png",
|
||||
"output": "static/assets/modules/auth/transparent/a01-scroll-dialog-v3.png",
|
||||
"logicalSlot": { "widthRpx": 620, "heightRpx": 520 },
|
||||
"outputPixels": { "width": 1860, "height": 1560 },
|
||||
"render": { "scalePolicy": "uniform-only", "uniMode": "aspectFit", "allowDistortion": false },
|
||||
"alpha": { "required": true, "transparentOuterPadding": 6, "cornerMaxAlpha": 0 },
|
||||
"edge": { "forbidChromaResidue": true, "forbidLightFringe": true, "premultipliedAlphaCheck": true },
|
||||
"quality": { "maxBytes": 3000000, "symmetry": "horizontal", "colorSpace": "sRGB" },
|
||||
"processing": { "trim": 1, "capWidth": 260, "keyColor": "#00FF00", "keyTolerance": 96, "paletteColors": 128, "indexedPng": true },
|
||||
"runtime": { "selector": ".verification-dialog", "imageSelector": ".verification-dialog__skin img" },
|
||||
"consumers": ["pages/auth/a01-entry.vue"]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,119 +0,0 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"canvas": {
|
||||
"width": 412,
|
||||
"height": 915
|
||||
},
|
||||
"shared": {
|
||||
"brandSeal": "static/assets/foundation/transparent/brand-seal.png",
|
||||
"lock": "static/assets/modules/auth/transparent/a01-icon-lock-v1.png",
|
||||
"wechat": "static/assets/foundation/transparent/auth-wechat.png",
|
||||
"agreementUnchecked": "static/assets/modules/auth/transparent/a02-agreement-unchecked.png"
|
||||
},
|
||||
"states": [
|
||||
{ "name": "password-hidden", "mode": "password", "passwordVisible": false },
|
||||
{ "name": "password-visible", "mode": "password", "passwordVisible": true },
|
||||
{ "name": "sms-default", "mode": "sms", "countdown": 0 },
|
||||
{ "name": "sms-countdown", "mode": "sms", "countdown": 56 }
|
||||
],
|
||||
"assets": [
|
||||
{
|
||||
"id": "header",
|
||||
"source": "static/assets/modules/auth/opaque/a01-vnext-header-v1.png",
|
||||
"output": "static/assets/modules/auth/opaque/a01-vnext-header-v1.png",
|
||||
"width": 824,
|
||||
"height": 340,
|
||||
"alpha": false,
|
||||
"maxBytes": 1200000
|
||||
},
|
||||
{
|
||||
"id": "scroll",
|
||||
"source": "static/assets/modules/auth/transparent/a01-vnext-scroll-v1.png",
|
||||
"output": "static/assets/modules/auth/transparent/a01-vnext-scroll-v1.png",
|
||||
"width": 784,
|
||||
"height": 1544,
|
||||
"alpha": true,
|
||||
"maxBytes": 4500000
|
||||
},
|
||||
{
|
||||
"id": "titleOrnament",
|
||||
"source": "static/assets/modules/auth/transparent/a01-vnext-title-ornament-v1.png",
|
||||
"output": "static/assets/modules/auth/transparent/a01-vnext-title-ornament-v1.png",
|
||||
"width": 376,
|
||||
"height": 130,
|
||||
"alpha": true,
|
||||
"maxBytes": 350000
|
||||
},
|
||||
{
|
||||
"id": "divider",
|
||||
"source": "static/assets/modules/auth/transparent/a01-vnext-divider-v1.png",
|
||||
"output": "static/assets/modules/auth/transparent/a01-vnext-divider-v1.png",
|
||||
"width": 540,
|
||||
"height": 90,
|
||||
"alpha": true,
|
||||
"maxBytes": 350000
|
||||
},
|
||||
{
|
||||
"id": "primaryButton",
|
||||
"source": "static/assets/modules/auth/transparent/a01-vnext-primary-button-v1.png",
|
||||
"output": "static/assets/modules/auth/transparent/a01-vnext-primary-button-v1.png",
|
||||
"width": 584,
|
||||
"height": 130,
|
||||
"alpha": true,
|
||||
"maxBytes": 350000
|
||||
},
|
||||
{
|
||||
"id": "secondaryButton",
|
||||
"source": "static/assets/modules/auth/transparent/a01-vnext-secondary-button-v1.png",
|
||||
"output": "static/assets/modules/auth/transparent/a01-vnext-secondary-button-v1.png",
|
||||
"width": 584,
|
||||
"height": 120,
|
||||
"alpha": true,
|
||||
"maxBytes": 350000
|
||||
},
|
||||
{
|
||||
"id": "phone",
|
||||
"source": "static/assets/modules/auth/transparent/a01-vnext-phone-v1.png",
|
||||
"output": "static/assets/modules/auth/transparent/a01-vnext-phone-v1.png",
|
||||
"width": 120,
|
||||
"height": 120,
|
||||
"alpha": true,
|
||||
"maxBytes": 120000
|
||||
},
|
||||
{
|
||||
"id": "eyeOpen",
|
||||
"source": "static/assets/modules/auth/transparent/a01-vnext-eye-open-v1.png",
|
||||
"output": "static/assets/modules/auth/transparent/a01-vnext-eye-open-v1.png",
|
||||
"width": 168,
|
||||
"height": 128,
|
||||
"alpha": true,
|
||||
"maxBytes": 150000
|
||||
},
|
||||
{
|
||||
"id": "eyeClosedPupil",
|
||||
"source": "static/assets/modules/auth/transparent/a01-vnext-eye-closed-pupil-v1.png",
|
||||
"output": "static/assets/modules/auth/transparent/a01-vnext-eye-closed-pupil-v1.png",
|
||||
"width": 168,
|
||||
"height": 140,
|
||||
"alpha": true,
|
||||
"maxBytes": 160000
|
||||
},
|
||||
{
|
||||
"id": "smsThreeDots",
|
||||
"source": "static/assets/modules/auth/transparent/a01-vnext-sms-three-dots-v1.png",
|
||||
"output": "static/assets/modules/auth/transparent/a01-vnext-sms-three-dots-v1.png",
|
||||
"width": 120,
|
||||
"height": 120,
|
||||
"alpha": true,
|
||||
"maxBytes": 120000
|
||||
}
|
||||
],
|
||||
"previews": [
|
||||
{ "state": "password-hidden", "output": "design-pipeline/generated/a01/A01-password-412x915.png", "width": 412, "height": 915 },
|
||||
{ "state": "sms-default", "output": "design-pipeline/generated/a01/A01-sms-412x915.png", "width": 412, "height": 915 },
|
||||
{ "state": "contact", "output": "design-pipeline/generated/a01/A01-password-sms-contact-824x915.png", "width": 824, "height": 915 },
|
||||
{ "state": "password-hidden", "output": "design-pipeline/generated/a01/A01-password-320x568.png", "width": 320, "height": 568 },
|
||||
{ "state": "password-hidden", "output": "design-pipeline/generated/a01/A01-password-360x640.png", "width": 360, "height": 640 },
|
||||
{ "state": "password-hidden", "output": "design-pipeline/generated/a01/A01-password-360x800.png", "width": 360, "height": 800 }
|
||||
]
|
||||
}
|
||||
@@ -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 }
|
||||
]
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"page": "G01",
|
||||
"status": "long-flagship-selected-for-genealogy-module",
|
||||
"selectedCandidateId": "g01-list-background-long-flagship",
|
||||
"runtimeOutput": "static/assets/modules/genealogy/opaque/genealogy-page-background-long.png",
|
||||
"generatedDate": "2026-07-16",
|
||||
"referenceAssets": [
|
||||
"static/assets/foundation/transparent/footer-mountain-bamboo.png",
|
||||
"tmp/g01-audit-02-application-gap-focused-412x915.png"
|
||||
],
|
||||
"determinism": {
|
||||
"modelRegeneration": "direction-only-not-pixel-identical",
|
||||
"savedMasterProcessing": "pixel-identical-with-locked-dependencies"
|
||||
},
|
||||
"chromaKey": {
|
||||
"color": "#00ff00",
|
||||
"dominanceStart": 30,
|
||||
"dominanceEnd": 220,
|
||||
"despillAllowance": 10
|
||||
},
|
||||
"candidates": [
|
||||
{
|
||||
"id": "g01-list-background-a",
|
||||
"title": "上山下亭纵向框景",
|
||||
"master": "docs/design/assets/g01-background/masters/g01-list-background-direction-a-chroma-master.png",
|
||||
"generatedOutput": "design-pipeline/generated/g01-background/g01-list-background-direction-a.png",
|
||||
"processingMode": "chroma-key",
|
||||
"sourcePixels": { "width": 1024, "height": 1536 },
|
||||
"prompt": "1024x1536 纵向 G01 列表背景独立资产;暖灰淡金中国水墨,远山从上方约 10% 开始,左中下亭台,右侧竹影贯穿,中央低对比度供列表文字阅读;无文字、无 UI、无卡片、无红色;非画面区域必须为纯 #00ff00 绿幕,画面本身不得使用绿色。"
|
||||
},
|
||||
{
|
||||
"id": "g01-list-background-b",
|
||||
"title": "上下山水环抱框景",
|
||||
"master": "docs/design/assets/g01-background/masters/g01-list-background-direction-b-chroma-master.png",
|
||||
"generatedOutput": "design-pipeline/generated/g01-background/g01-list-background-direction-b.png",
|
||||
"processingMode": "chroma-key",
|
||||
"sourcePixels": { "width": 1024, "height": 1536 },
|
||||
"prompt": "1024x1536 纵向 G01 列表背景独立资产;上方连续远山与雾带、左中亭台、右侧淡金竹影、下方第二层山水,三个纵向区段都有内容但中央保持安静;无文字、无 UI、无卡片、无红色;非画面区域必须为纯 #00ff00 绿幕,画面本身不得使用绿色。"
|
||||
},
|
||||
{
|
||||
"id": "g01-list-background-c",
|
||||
"title": "云竹亭台连续宣纸景",
|
||||
"master": "docs/design/assets/g01-background/masters/g01-list-background-direction-c-paper-master.png",
|
||||
"generatedOutput": "design-pipeline/generated/g01-background/g01-list-background-direction-c.png",
|
||||
"processingMode": "opaque-paper",
|
||||
"sourcePixels": { "width": 1024, "height": 1536 },
|
||||
"prompt": "1024x1536 纵向 G01 列表背景独立资产;暖宣纸底,顶部 5% 即出现云气、枝叶和淡金竹影,左侧约 40% 高度放置亭台,山水从中部连续延伸到底部,中央低对比度;无文字、无 UI、无卡片、无标志、无红色。"
|
||||
},
|
||||
{
|
||||
"id": "g01-list-background-long-flagship",
|
||||
"title": "旗舰长屏连续云竹亭台宣纸景",
|
||||
"imageGenSource": "docs/design/assets/g01-background/masters/genealogy-page-background-long-flagship-imagegen-source.png",
|
||||
"master": "docs/design/assets/g01-background/masters/genealogy-page-background-long-flagship-master.png",
|
||||
"generatedOutput": "design-pipeline/generated/g01-background/genealogy-page-background-long-flagship.png",
|
||||
"processingMode": "opaque-paper-resize",
|
||||
"sourcePixels": { "width": 1536, "height": 3840 },
|
||||
"outputPixels": { "width": 1440, "height": 3600 },
|
||||
"prompt": "1536x3840 纵向 G 类型共享连续长背景;延续 C 方向的暖灰宣纸、淡金竹影和灰褐水墨,顶部疏竹淡云作为短屏安全裁切区,中段低对比留给页面内容,下半部连续布置亭台、湖面、远山和岸边竹影;无文字、无 Logo、无 UI、无卡片、无按钮、无红色元素、无横向接缝、无重复图案。"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
{
|
||||
"version": 1,
|
||||
"width": 1440,
|
||||
"height": 3600,
|
||||
"backgrounds": [
|
||||
{
|
||||
"module": "tree",
|
||||
"source": "docs/design/assets/module-backgrounds/masters/tree-page-background-imagegen-source.png",
|
||||
"output": "static/assets/modules/tree/opaque/tree-page-background-long.png"
|
||||
},
|
||||
{
|
||||
"module": "family",
|
||||
"source": "docs/design/assets/module-backgrounds/masters/family-page-background-imagegen-source.png",
|
||||
"output": "static/assets/modules/family/opaque/family-page-background-long.png"
|
||||
},
|
||||
{
|
||||
"module": "records",
|
||||
"source": "docs/design/assets/module-backgrounds/masters/records-page-background-imagegen-source.png",
|
||||
"output": "static/assets/modules/records/opaque/records-page-background-long.png"
|
||||
},
|
||||
{
|
||||
"module": "notification",
|
||||
"source": "docs/design/assets/module-backgrounds/masters/notification-page-background-imagegen-source.png",
|
||||
"output": "static/assets/modules/notification/opaque/notification-page-background-long.png"
|
||||
},
|
||||
{
|
||||
"module": "profile",
|
||||
"source": "docs/design/assets/module-backgrounds/masters/profile-page-background-imagegen-source.png",
|
||||
"output": "static/assets/modules/profile/opaque/profile-page-background-long.png"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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": []
|
||||
}
|
||||
Generated
+1
-599
@@ -4,605 +4,7 @@
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "jiapu-design-pipeline",
|
||||
"dependencies": {
|
||||
"sharp": "0.34.5"
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/runtime": {
|
||||
"version": "1.11.2",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.2.tgz",
|
||||
"integrity": "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/colour": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
|
||||
"integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-darwin-arm64": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz",
|
||||
"integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-darwin-arm64": "1.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-darwin-x64": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz",
|
||||
"integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-darwin-x64": "1.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-darwin-arm64": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz",
|
||||
"integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-darwin-x64": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz",
|
||||
"integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-arm": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz",
|
||||
"integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-arm64": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz",
|
||||
"integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-ppc64": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz",
|
||||
"integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-riscv64": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz",
|
||||
"integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-s390x": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz",
|
||||
"integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-x64": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz",
|
||||
"integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linuxmusl-arm64": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz",
|
||||
"integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linuxmusl-x64": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz",
|
||||
"integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-arm": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz",
|
||||
"integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-arm": "1.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-arm64": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz",
|
||||
"integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-arm64": "1.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-ppc64": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz",
|
||||
"integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-ppc64": "1.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-riscv64": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz",
|
||||
"integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-riscv64": "1.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-s390x": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz",
|
||||
"integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-s390x": "1.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-x64": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz",
|
||||
"integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-x64": "1.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linuxmusl-arm64": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz",
|
||||
"integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linuxmusl-arm64": "1.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linuxmusl-x64": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz",
|
||||
"integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linuxmusl-x64": "1.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-wasm32": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz",
|
||||
"integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==",
|
||||
"cpu": [
|
||||
"wasm32"
|
||||
],
|
||||
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@emnapi/runtime": "^1.7.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-win32-arm64": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz",
|
||||
"integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "Apache-2.0 AND LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-win32-ia32": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz",
|
||||
"integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"license": "Apache-2.0 AND LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-win32-x64": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz",
|
||||
"integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "Apache-2.0 AND LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/detect-libc": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/semver": {
|
||||
"version": "7.8.5",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
|
||||
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/sharp": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz",
|
||||
"integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==",
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@img/colour": "^1.0.0",
|
||||
"detect-libc": "^2.1.2",
|
||||
"semver": "^7.7.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-darwin-arm64": "0.34.5",
|
||||
"@img/sharp-darwin-x64": "0.34.5",
|
||||
"@img/sharp-libvips-darwin-arm64": "1.2.4",
|
||||
"@img/sharp-libvips-darwin-x64": "1.2.4",
|
||||
"@img/sharp-libvips-linux-arm": "1.2.4",
|
||||
"@img/sharp-libvips-linux-arm64": "1.2.4",
|
||||
"@img/sharp-libvips-linux-ppc64": "1.2.4",
|
||||
"@img/sharp-libvips-linux-riscv64": "1.2.4",
|
||||
"@img/sharp-libvips-linux-s390x": "1.2.4",
|
||||
"@img/sharp-libvips-linux-x64": "1.2.4",
|
||||
"@img/sharp-libvips-linuxmusl-arm64": "1.2.4",
|
||||
"@img/sharp-libvips-linuxmusl-x64": "1.2.4",
|
||||
"@img/sharp-linux-arm": "0.34.5",
|
||||
"@img/sharp-linux-arm64": "0.34.5",
|
||||
"@img/sharp-linux-ppc64": "0.34.5",
|
||||
"@img/sharp-linux-riscv64": "0.34.5",
|
||||
"@img/sharp-linux-s390x": "0.34.5",
|
||||
"@img/sharp-linux-x64": "0.34.5",
|
||||
"@img/sharp-linuxmusl-arm64": "0.34.5",
|
||||
"@img/sharp-linuxmusl-x64": "0.34.5",
|
||||
"@img/sharp-wasm32": "0.34.5",
|
||||
"@img/sharp-win32-arm64": "0.34.5",
|
||||
"@img/sharp-win32-ia32": "0.34.5",
|
||||
"@img/sharp-win32-x64": "0.34.5"
|
||||
}
|
||||
},
|
||||
"node_modules/tslib": {
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"license": "0BSD",
|
||||
"optional": true
|
||||
"name": "jiapu-design-pipeline"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,18 +3,7 @@
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build:a01": "node scripts/build-a01.mjs",
|
||||
"test:v2": "node --test tests/*.test.mjs",
|
||||
"validate:a01-buttons": "node scripts/validate-manifest-v2.mjs manifests/a01-buttons-v2.json",
|
||||
"validate:a01-scroll-skins": "node scripts/validate-manifest-v2.mjs manifests/a01-scroll-skins-v3.json",
|
||||
"rebuild:a01-buttons": "node scripts/rebuild-a01-buttons.mjs",
|
||||
"verify:assets": "node scripts/verify-assets.mjs",
|
||||
"build:a01-scroll-skins": "node scripts/build-a01-scroll-skins.mjs",
|
||||
"verify:a01-scroll-skins": "node scripts/verify-a01-scroll-skins.mjs",
|
||||
"build:g01-background-candidates": "node scripts/build-g01-backgrounds.mjs",
|
||||
"build:g01-empty-frame": "node scripts/build-g01-empty-frame.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"sharp": "0.34.5"
|
||||
"validate:runtime-assets": "node scripts/validate-runtime-asset-inventory.mjs design-pipeline/manifests/runtime-assets.json",
|
||||
"check": "npm run validate:runtime-assets"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
Pillow==12.3.0
|
||||
@@ -1,98 +0,0 @@
|
||||
"""Deterministic PNG quality checks for generated design assets."""
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from PIL import Image
|
||||
|
||||
|
||||
def _outer_ring_pixels(image: Image.Image, thickness: int):
|
||||
width, height = image.size
|
||||
thickness = max(0, min(thickness, width // 2, height // 2))
|
||||
for y in range(height):
|
||||
for x in range(width):
|
||||
if x < thickness or x >= width - thickness or y < thickness or y >= height - thickness:
|
||||
yield image.getpixel((x, y))
|
||||
|
||||
|
||||
def analyze_asset(path: Path, spec: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Analyze one PNG against its manifest v2 specification."""
|
||||
path = Path(path)
|
||||
errors: list[str] = []
|
||||
warnings: list[str] = []
|
||||
metrics: dict[str, int] = {}
|
||||
|
||||
with Image.open(path) as opened:
|
||||
width, height = opened.size
|
||||
mode = opened.mode
|
||||
info = dict(opened.info)
|
||||
has_alpha = "A" in opened.getbands() or "transparency" in opened.info
|
||||
image = opened.convert("RGBA")
|
||||
|
||||
expected = spec["outputPixels"]
|
||||
if (width, height) != (expected["width"], expected["height"]):
|
||||
errors.append(
|
||||
f"dimensions expected {expected['width']}x{expected['height']}, got {width}x{height}"
|
||||
)
|
||||
|
||||
alpha = spec.get("alpha", {})
|
||||
if alpha.get("required") and not has_alpha:
|
||||
errors.append(f"alpha-required asset has no alpha channel or transparency table, got {mode}")
|
||||
|
||||
padding = int(alpha.get("transparentOuterPadding", 0))
|
||||
max_alpha = int(alpha.get("cornerMaxAlpha", 0))
|
||||
opaque_padding = sum(1 for pixel in _outer_ring_pixels(image, padding) if pixel[3] > max_alpha)
|
||||
metrics["outerPaddingViolations"] = opaque_padding
|
||||
if opaque_padding:
|
||||
errors.append(f"outer padding contains {opaque_padding} pixels above alpha {max_alpha}")
|
||||
|
||||
edge = spec.get("edge", {})
|
||||
pixels = list(image.get_flattened_data())
|
||||
|
||||
chroma_residue = sum(
|
||||
1
|
||||
for red, green, blue, pixel_alpha in pixels
|
||||
if pixel_alpha > 8 and green > 120 and green - max(red, blue) > 80
|
||||
)
|
||||
metrics["chromaResiduePixels"] = chroma_residue
|
||||
if edge.get("forbidChromaResidue") and chroma_residue:
|
||||
errors.append(f"chroma residue detected in {chroma_residue} visible pixels")
|
||||
|
||||
light_fringe = sum(
|
||||
1
|
||||
for red, green, blue, pixel_alpha in pixels
|
||||
if 0 < pixel_alpha < 255 and red > 235 and green > 235 and blue > 235
|
||||
)
|
||||
metrics["lightFringePixels"] = light_fringe
|
||||
if edge.get("forbidLightFringe") and light_fringe:
|
||||
errors.append(f"light fringe detected in {light_fringe} partially transparent pixels")
|
||||
|
||||
transparent_rgb = sum(
|
||||
1
|
||||
for red, green, blue, pixel_alpha in pixels
|
||||
if pixel_alpha == 0 and (red != 0 or green != 0 or blue != 0)
|
||||
)
|
||||
metrics["transparentRgbPixels"] = transparent_rgb
|
||||
if edge.get("premultipliedAlphaCheck") and transparent_rgb:
|
||||
errors.append(f"transparent RGB detected in {transparent_rgb} fully transparent pixels")
|
||||
|
||||
byte_size = path.stat().st_size
|
||||
max_bytes = int(spec.get("quality", {}).get("maxBytes", 0))
|
||||
if max_bytes and byte_size > max_bytes:
|
||||
errors.append(f"maxBytes {max_bytes} exceeded by file size {byte_size}")
|
||||
|
||||
expected_color_space = spec.get("quality", {}).get("colorSpace")
|
||||
if expected_color_space == "sRGB" and not ("srgb" in info or "icc_profile" in info):
|
||||
warnings.append("PNG does not declare an sRGB chunk or ICC profile")
|
||||
|
||||
return {
|
||||
"id": spec.get("id", path.stem),
|
||||
"path": str(path),
|
||||
"width": width,
|
||||
"height": height,
|
||||
"mode": mode,
|
||||
"bytes": byte_size,
|
||||
"errors": errors,
|
||||
"warnings": warnings,
|
||||
"metrics": metrics,
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { loadAndValidateManifest } from './manifest-v2.mjs'
|
||||
|
||||
const scriptDirectory = path.dirname(fileURLToPath(import.meta.url))
|
||||
const pipelineDirectory = path.resolve(scriptDirectory, '..')
|
||||
const workspace = path.resolve(pipelineDirectory, '..')
|
||||
const manifestPath = path.join(pipelineDirectory, 'manifests', 'a01-scroll-skins-v3.json')
|
||||
const reportPath = path.join(pipelineDirectory, 'generated', 'a01-scroll-skins-v3', 'quality-report.json')
|
||||
const localPython = path.join(pipelineDirectory, '.venv', 'Scripts', 'python.exe')
|
||||
const python = process.env.PYTHON || (fs.existsSync(localPython) ? localPython : 'python')
|
||||
|
||||
await loadAndValidateManifest(manifestPath, workspace)
|
||||
|
||||
function run(script, args) {
|
||||
const result = spawnSync(python, [path.join(scriptDirectory, script), ...args], {
|
||||
cwd: workspace,
|
||||
encoding: 'utf8',
|
||||
stdio: 'inherit',
|
||||
})
|
||||
if (result.error) throw new Error(`无法启动 Python(${python}):${result.error.message}`)
|
||||
if (result.status !== 0) process.exit(result.status ?? 1)
|
||||
}
|
||||
|
||||
run('build_scroll_skins.py', [manifestPath, '--workspace', workspace])
|
||||
run('verify-assets.py', [manifestPath, '--workspace', workspace, '--report', reportPath])
|
||||
@@ -1,159 +0,0 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import { mkdir, readFile, stat, writeFile } from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import sharp from 'sharp'
|
||||
|
||||
const scriptDirectory = path.dirname(fileURLToPath(import.meta.url))
|
||||
const workspace = path.resolve(scriptDirectory, '..', '..')
|
||||
const manifestPath = path.join(workspace, 'design-pipeline', 'manifests', 'a01.json')
|
||||
const manifest = JSON.parse(await readFile(manifestPath, 'utf8'))
|
||||
const generatedDirectory = path.join(workspace, 'design-pipeline', 'generated', 'a01')
|
||||
|
||||
const resolveWorkspacePath = (relativePath) => {
|
||||
const absolutePath = path.resolve(workspace, relativePath)
|
||||
const relative = path.relative(workspace, absolutePath)
|
||||
if (relative.startsWith('..') || path.isAbsolute(relative)) {
|
||||
throw new Error(`路径越出工作区:${relativePath}`)
|
||||
}
|
||||
return absolutePath
|
||||
}
|
||||
|
||||
const sha256 = (buffer) => createHash('sha256').update(buffer).digest('hex')
|
||||
|
||||
const writeVersionedFile = async (outputPath, buffer) => {
|
||||
await mkdir(path.dirname(outputPath), { recursive: true })
|
||||
try {
|
||||
const current = await readFile(outputPath)
|
||||
if (sha256(current) !== sha256(buffer)) {
|
||||
throw new Error(`拒绝覆盖内容不同的版本化输出:${path.relative(workspace, outputPath)}`)
|
||||
}
|
||||
return
|
||||
} catch (error) {
|
||||
if (error.code !== 'ENOENT') throw error
|
||||
}
|
||||
await writeFile(outputPath, buffer)
|
||||
}
|
||||
|
||||
const runtimeAssets = new Map()
|
||||
const reportAssets = []
|
||||
|
||||
for (const asset of manifest.assets) {
|
||||
const sourcePath = resolveWorkspacePath(asset.source)
|
||||
const outputPath = resolveWorkspacePath(asset.output)
|
||||
await stat(sourcePath)
|
||||
const pipeline = sharp(sourcePath).resize(asset.width, asset.height, { fit: 'fill' })
|
||||
if (!asset.alpha) pipeline.flatten({ background: '#f4eadc' })
|
||||
const buffer = await pipeline.png({ compressionLevel: 9, adaptiveFiltering: true, palette: false }).toBuffer()
|
||||
await writeVersionedFile(outputPath, buffer)
|
||||
runtimeAssets.set(asset.id, buffer)
|
||||
const metadata = await sharp(buffer).metadata()
|
||||
reportAssets.push({
|
||||
id: asset.id,
|
||||
output: asset.output,
|
||||
width: metadata.width,
|
||||
height: metadata.height,
|
||||
bytes: buffer.length,
|
||||
sha256: sha256(buffer)
|
||||
})
|
||||
}
|
||||
|
||||
const loadShared = async (key) => readFile(resolveWorkspacePath(manifest.shared[key]))
|
||||
const shared = {
|
||||
brandSeal: await loadShared('brandSeal'),
|
||||
lock: await loadShared('lock'),
|
||||
wechat: await loadShared('wechat'),
|
||||
agreementUnchecked: await loadShared('agreementUnchecked')
|
||||
}
|
||||
|
||||
const svgLayer = (mode) => Buffer.from(`
|
||||
<svg width="412" height="915" xmlns="http://www.w3.org/2000/svg">
|
||||
<style>
|
||||
.k { font-family: KaiTi, STKaiti, serif; }
|
||||
.red { fill: #9f1717; }
|
||||
.gold { fill: #a97936; }
|
||||
.body { fill: #51443b; }
|
||||
.muted { fill: #9a8e84; }
|
||||
</style>
|
||||
<text x="206" y="321" text-anchor="middle" class="k red" font-size="36">登录家谱</text>
|
||||
<text x="130" y="389" text-anchor="middle" class="k ${mode === 'password' ? 'red' : 'gold'}" font-size="20">密码登录</text>
|
||||
<text x="282" y="389" text-anchor="middle" class="k ${mode === 'sms' ? 'red' : 'gold'}" font-size="20">验证码登录</text>
|
||||
<line x1="61" y1="404" x2="351" y2="404" stroke="#d1a965" stroke-width="1"/>
|
||||
<line x1="${mode === 'password' ? 99 : 251}" y1="401" x2="${mode === 'password' ? 161 : 313}" y2="401" stroke="#bd151b" stroke-width="4"/>
|
||||
<text x="112" y="449" class="k muted" font-size="18">手机号</text>
|
||||
<line x1="61" y1="481" x2="351" y2="481" stroke="#d1a965" stroke-width="1"/>
|
||||
<text x="112" y="559" class="k muted" font-size="18">${mode === 'password' ? '密码' : '验证码'}</text>
|
||||
${mode === 'password'
|
||||
? '<text x="349" y="590" text-anchor="end" class="k red" font-size="15">忘记密码</text>'
|
||||
: '<text x="349" y="559" text-anchor="end" class="k red" font-size="15">获取验证码</text>'}
|
||||
<line x1="61" y1="576" x2="351" y2="576" stroke="#d1a965" stroke-width="1"/>
|
||||
<text x="206" y="646" text-anchor="middle" class="k" fill="#fffaf2" font-size="26">登录</text>
|
||||
<line x1="62" y1="692" x2="151" y2="692" stroke="#d1a965"/>
|
||||
<line x1="261" y1="692" x2="350" y2="692" stroke="#d1a965"/>
|
||||
<text x="206" y="699" text-anchor="middle" class="k gold" font-size="15">其他登录方式</text>
|
||||
<text x="219" y="761" text-anchor="middle" class="k body" font-size="20">微信登录</text>
|
||||
<text x="206" y="812" text-anchor="middle" class="k body" font-size="14">还没有账号? <tspan class="red">注册账号</tspan></text>
|
||||
<text x="93" y="851" class="k body" font-size="11">我已阅读并同意《用户协议》与《隐私政策》</text>
|
||||
</svg>`)
|
||||
|
||||
const sized = async (input, width, height) => sharp(input).resize(width, height, { fit: 'fill' }).png().toBuffer()
|
||||
|
||||
const buildBasePreview = async (mode) => {
|
||||
const stateIcon = mode === 'password' ? shared.lock : runtimeAssets.get('smsThreeDots')
|
||||
const rightIcon = mode === 'password' ? runtimeAssets.get('eyeClosedPupil') : null
|
||||
const layers = [
|
||||
{ input: await sized(runtimeAssets.get('header'), 412, 170), left: 0, top: 0 },
|
||||
{ input: await sized(runtimeAssets.get('scroll'), 392, 772), left: 10, top: 143 },
|
||||
{ input: await sized(shared.brandSeal, 72, 86), left: 170, top: 35 },
|
||||
{ input: await sized(runtimeAssets.get('titleOrnament'), 126, 44), left: 143, top: 235 },
|
||||
{ input: await sized(runtimeAssets.get('divider'), 180, 30), left: 116, top: 330 },
|
||||
{ input: await sized(runtimeAssets.get('phone'), 40, 40), left: 68, top: 420 },
|
||||
{ input: await sized(stateIcon, 40, 40), left: 68, top: 522 },
|
||||
{ input: await sized(runtimeAssets.get('primaryButton'), 292, 65), left: 60, top: 608 },
|
||||
{ input: await sized(runtimeAssets.get('secondaryButton'), 292, 60), left: 60, top: 718 },
|
||||
{ input: await sized(shared.wechat, 36, 36), left: 126, top: 730 },
|
||||
{ input: await sized(shared.agreementUnchecked, 24, 24), left: 62, top: 831 },
|
||||
{ input: svgLayer(mode), left: 0, top: 0 }
|
||||
]
|
||||
if (rightIcon) layers.splice(7, 0, { input: await sized(rightIcon, 42, 35), left: 312, top: 525 })
|
||||
return sharp({ create: { width: 412, height: 915, channels: 4, background: '#f4eadc' } })
|
||||
.composite(layers)
|
||||
.png({ compressionLevel: 9, adaptiveFiltering: true })
|
||||
.toBuffer()
|
||||
}
|
||||
|
||||
await mkdir(generatedDirectory, { recursive: true })
|
||||
const passwordPreview = await buildBasePreview('password')
|
||||
const smsPreview = await buildBasePreview('sms')
|
||||
|
||||
const writeGenerated = async (relativePath, buffer) => {
|
||||
const outputPath = resolveWorkspacePath(relativePath)
|
||||
await mkdir(path.dirname(outputPath), { recursive: true })
|
||||
await writeFile(outputPath, buffer)
|
||||
}
|
||||
|
||||
await writeGenerated('design-pipeline/generated/a01/A01-password-412x915.png', passwordPreview)
|
||||
await writeGenerated('design-pipeline/generated/a01/A01-sms-412x915.png', smsPreview)
|
||||
const contact = await sharp({ create: { width: 824, height: 915, channels: 4, background: '#ffffff' } })
|
||||
.composite([{ input: passwordPreview, left: 0, top: 0 }, { input: smsPreview, left: 412, top: 0 }])
|
||||
.png({ compressionLevel: 9, adaptiveFiltering: true })
|
||||
.toBuffer()
|
||||
await writeGenerated('design-pipeline/generated/a01/A01-password-sms-contact-824x915.png', contact)
|
||||
|
||||
for (const preview of manifest.previews.filter((item) => item.width !== 412 && item.width !== 824)) {
|
||||
const compact = await sharp(passwordPreview)
|
||||
.resize(preview.width, preview.height, { fit: 'fill' })
|
||||
.png({ compressionLevel: 9, adaptiveFiltering: true })
|
||||
.toBuffer()
|
||||
await writeGenerated(preview.output, compact)
|
||||
}
|
||||
|
||||
const buildReport = {
|
||||
schemaVersion: 1,
|
||||
manifest: path.relative(workspace, manifestPath).replaceAll('\\', '/'),
|
||||
assets: reportAssets,
|
||||
generatedAt: new Date().toISOString()
|
||||
}
|
||||
await writeGenerated('design-pipeline/generated/a01/build-report.json', Buffer.from(`${JSON.stringify(buildReport, null, 2)}\n`))
|
||||
|
||||
console.log('A01-CODE-PIPELINE BUILD PASS')
|
||||
@@ -1,33 +0,0 @@
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const scriptDirectory = path.dirname(fileURLToPath(import.meta.url))
|
||||
const pipelineDirectory = path.resolve(scriptDirectory, '..')
|
||||
const workspace = path.resolve(pipelineDirectory, '..')
|
||||
const manifestPath = path.join(pipelineDirectory, 'manifests', 'g01-background-candidates.json')
|
||||
const reportPath = path.join(pipelineDirectory, 'generated', 'g01-background', 'build-report.json')
|
||||
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'))
|
||||
|
||||
if (manifest.schemaVersion !== 1 || manifest.page !== 'G01' || manifest.candidates?.length !== 4) {
|
||||
throw new Error('G01 background manifest must declare exactly four schema v1 candidates')
|
||||
}
|
||||
|
||||
for (const candidate of manifest.candidates) {
|
||||
const source = path.resolve(workspace, candidate.master)
|
||||
const relative = path.relative(workspace, source)
|
||||
if (relative.startsWith('..') || path.isAbsolute(relative)) throw new Error(`${candidate.id} master escapes workspace`)
|
||||
if (!fs.existsSync(source)) throw new Error(`${candidate.id} master is missing: ${candidate.master}`)
|
||||
}
|
||||
|
||||
const localPython = path.join(pipelineDirectory, '.venv', 'Scripts', 'python.exe')
|
||||
const python = process.env.PYTHON || (fs.existsSync(localPython) ? localPython : 'python')
|
||||
const result = spawnSync(
|
||||
python,
|
||||
[path.join(scriptDirectory, 'build_g01_backgrounds.py'), manifestPath, '--workspace', workspace, '--report', reportPath],
|
||||
{ cwd: workspace, encoding: 'utf8', stdio: 'inherit' }
|
||||
)
|
||||
|
||||
if (result.error) throw new Error(`无法启动 Python(${python}):${result.error.message}`)
|
||||
if (result.status !== 0) process.exit(result.status ?? 1)
|
||||
@@ -1,51 +0,0 @@
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import sharp from 'sharp'
|
||||
|
||||
const scriptDirectory = path.dirname(fileURLToPath(import.meta.url))
|
||||
const projectRoot = path.resolve(scriptDirectory, '..', '..')
|
||||
const sourcePath = path.join(projectRoot, 'static', 'assets', 'modules', 'genealogy', 'opaque', 'g01-empty-panel.png')
|
||||
const outputPath = path.join(projectRoot, 'static', 'assets', 'modules', 'genealogy', 'transparent', 'g01-empty-panel-frame.png')
|
||||
const borderBand = 110
|
||||
|
||||
const { data, info } = await sharp(sourcePath)
|
||||
.ensureAlpha()
|
||||
.raw()
|
||||
.toBuffer({ resolveWithObject: true })
|
||||
|
||||
for (let y = 0; y < info.height; y += 1) {
|
||||
for (let x = 0; x < info.width; x += 1) {
|
||||
const offset = (y * info.width + x) * info.channels
|
||||
const red = data[offset]
|
||||
const green = data[offset + 1]
|
||||
const blue = data[offset + 2]
|
||||
const alpha = data[offset + 3]
|
||||
const distanceToEdge = Math.min(x, y, info.width - 1 - x, info.height - 1 - y)
|
||||
const isWarmGold = red > green
|
||||
&& green > blue
|
||||
&& red - green >= 15
|
||||
&& green - blue >= 12
|
||||
&& red - blue >= 35
|
||||
&& red < 245
|
||||
&& blue < 180
|
||||
|
||||
if (distanceToEdge >= borderBand || !isWarmGold) {
|
||||
data[offset + 3] = 0
|
||||
continue
|
||||
}
|
||||
|
||||
const edgeAlpha = Math.max(0, Math.min(255, (red - blue - 25) * 6))
|
||||
data[offset + 3] = Math.min(alpha, edgeAlpha)
|
||||
}
|
||||
}
|
||||
|
||||
await sharp(data, {
|
||||
raw: {
|
||||
width: info.width,
|
||||
height: info.height,
|
||||
channels: info.channels
|
||||
}
|
||||
}).png().toFile(outputPath)
|
||||
|
||||
process.stdout.write(`BUILT ${path.relative(projectRoot, outputPath)}\n`)
|
||||
|
||||
@@ -1,128 +0,0 @@
|
||||
"""Deterministically build G01 background candidates from saved ImageGen masters."""
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from PIL import Image, PngImagePlugin
|
||||
|
||||
|
||||
def process_chroma_image(source: Image.Image, config: dict[str, Any]) -> Image.Image:
|
||||
"""Convert a green-screen master to clean RGBA with edge despill."""
|
||||
start = int(config["dominanceStart"])
|
||||
end = int(config["dominanceEnd"])
|
||||
allowance = int(config["despillAllowance"])
|
||||
if end <= start:
|
||||
raise ValueError("dominanceEnd must be greater than dominanceStart")
|
||||
|
||||
output = Image.new("RGBA", source.size, (0, 0, 0, 0))
|
||||
converted = []
|
||||
for red, green, blue, source_alpha in source.convert("RGBA").get_flattened_data():
|
||||
dominance = green - max(red, blue)
|
||||
if green >= 100 and dominance > start:
|
||||
removal = min(1.0, (dominance - start) / (end - start))
|
||||
alpha = round(source_alpha * (1.0 - removal))
|
||||
if alpha <= 2:
|
||||
converted.append((0, 0, 0, 0))
|
||||
continue
|
||||
green = min(green, max(red, blue) + allowance)
|
||||
converted.append((red, green, blue, alpha))
|
||||
else:
|
||||
converted.append((red, green, blue, source_alpha))
|
||||
output.putdata(converted)
|
||||
return output
|
||||
|
||||
|
||||
def process_opaque_image(source: Image.Image, output_size: tuple[int, int] | None = None) -> Image.Image:
|
||||
"""Normalize a paper-backed master to deterministic opaque RGBA."""
|
||||
output = source.convert("RGBA")
|
||||
if output_size is not None and output.size != output_size:
|
||||
output = output.resize(output_size, Image.Resampling.LANCZOS)
|
||||
output.putalpha(255)
|
||||
return output
|
||||
|
||||
|
||||
def save_png(image: Image.Image, path: Path) -> None:
|
||||
"""Save a deterministic PNG with an explicit standard-sRGB chunk."""
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
png_info = PngImagePlugin.PngInfo()
|
||||
png_info.add(b"sRGB", b"\x00")
|
||||
image.save(path, format="PNG", optimize=True, compress_level=9, pnginfo=png_info)
|
||||
|
||||
|
||||
def _resolve_inside(workspace: Path, relative_path: str) -> Path:
|
||||
absolute = (workspace / relative_path).resolve()
|
||||
try:
|
||||
absolute.relative_to(workspace.resolve())
|
||||
except ValueError as error:
|
||||
raise ValueError(f"path escapes workspace: {relative_path}") from error
|
||||
return absolute
|
||||
|
||||
|
||||
def _sha256(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as stream:
|
||||
for chunk in iter(lambda: stream.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def build(manifest_path: Path, workspace: Path, report_path: Path) -> list[dict[str, Any]]:
|
||||
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||
chroma = manifest["chromaKey"]
|
||||
reports = []
|
||||
|
||||
for candidate in manifest["candidates"]:
|
||||
source_path = _resolve_inside(workspace, candidate["master"])
|
||||
output_path = _resolve_inside(workspace, candidate["generatedOutput"])
|
||||
with Image.open(source_path) as opened:
|
||||
expected = candidate["sourcePixels"]
|
||||
if opened.size != (expected["width"], expected["height"]):
|
||||
raise ValueError(
|
||||
f"{candidate['id']} expected {expected['width']}x{expected['height']}, got {opened.width}x{opened.height}"
|
||||
)
|
||||
if candidate["processingMode"] == "chroma-key":
|
||||
output = process_chroma_image(opened, chroma)
|
||||
elif candidate["processingMode"] in {"opaque-paper", "opaque-paper-resize"}:
|
||||
output_size = None
|
||||
if candidate["processingMode"] == "opaque-paper-resize":
|
||||
target = candidate["outputPixels"]
|
||||
output_size = (target["width"], target["height"])
|
||||
output = process_opaque_image(opened, output_size)
|
||||
else:
|
||||
raise ValueError(f"unsupported processingMode: {candidate['processingMode']}")
|
||||
|
||||
save_png(output, output_path)
|
||||
reports.append(
|
||||
{
|
||||
"id": candidate["id"],
|
||||
"mode": candidate["processingMode"],
|
||||
"source": candidate["master"],
|
||||
"output": candidate["generatedOutput"],
|
||||
"width": output.width,
|
||||
"height": output.height,
|
||||
"bytes": output_path.stat().st_size,
|
||||
"sha256": _sha256(output_path),
|
||||
}
|
||||
)
|
||||
|
||||
report_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
report_path.write_text(json.dumps(reports, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
return reports
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("manifest", type=Path)
|
||||
parser.add_argument("--workspace", type=Path, required=True)
|
||||
parser.add_argument("--report", type=Path, required=True)
|
||||
args = parser.parse_args()
|
||||
reports = build(args.manifest, args.workspace.resolve(), args.report)
|
||||
for report in reports:
|
||||
print(f"G01-BACKGROUND-BUILD PASS {report['id']} {report['width']}x{report['height']} {report['sha256']}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,61 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
MANIFEST = ROOT / "design-pipeline/manifests/module-page-backgrounds.json"
|
||||
|
||||
|
||||
def sha256(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as stream:
|
||||
for chunk in iter(lambda: stream.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def normalize(source: Path, output: Path, width: int, height: int) -> None:
|
||||
with Image.open(source) as image:
|
||||
image = image.convert("RGB")
|
||||
scale = max(width / image.width, height / image.height)
|
||||
resized = image.resize(
|
||||
(round(image.width * scale), round(image.height * scale)),
|
||||
Image.Resampling.LANCZOS,
|
||||
)
|
||||
left = max(0, (resized.width - width) // 2)
|
||||
top = max(0, (resized.height - height) // 2)
|
||||
normalized = resized.crop((left, top, left + width, top + height))
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
normalized.save(output, format="PNG", optimize=True)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
manifest = json.loads(MANIFEST.read_text(encoding="utf-8"))
|
||||
width = int(manifest["width"])
|
||||
height = int(manifest["height"])
|
||||
report = []
|
||||
for item in manifest["backgrounds"]:
|
||||
source = ROOT / item["source"]
|
||||
output = ROOT / item["output"]
|
||||
if not source.is_file():
|
||||
raise FileNotFoundError(source)
|
||||
normalize(source, output, width, height)
|
||||
report.append(
|
||||
{
|
||||
"module": item["module"],
|
||||
"source": item["source"],
|
||||
"output": item["output"],
|
||||
"size": [width, height],
|
||||
"sha256": sha256(output),
|
||||
}
|
||||
)
|
||||
print(json.dumps(report, ensure_ascii=False, indent=2))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,176 +0,0 @@
|
||||
"""Build exact A01 scroll-skin outputs from chroma-backed visual masters."""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import math
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image, PngImagePlugin
|
||||
|
||||
|
||||
def remove_chroma_background(
|
||||
image: Image.Image,
|
||||
key: tuple[int, int, int],
|
||||
tolerance: int,
|
||||
feather: int = 72,
|
||||
) -> Image.Image:
|
||||
source = image.convert("RGBA")
|
||||
cleaned = Image.new("RGBA", source.size, (0, 0, 0, 0))
|
||||
result = []
|
||||
key_red, key_green, key_blue = key
|
||||
|
||||
for red, green, blue, source_alpha in source.get_flattened_data():
|
||||
distance = math.sqrt(
|
||||
(red - key_red) ** 2 + (green - key_green) ** 2 + (blue - key_blue) ** 2
|
||||
)
|
||||
alpha_fraction = max(0.0, min(1.0, (distance - tolerance) / feather))
|
||||
alpha_fraction *= source_alpha / 255
|
||||
if alpha_fraction <= 0:
|
||||
result.append((0, 0, 0, 0))
|
||||
continue
|
||||
|
||||
recovered_red = round((red - (1 - alpha_fraction) * key_red) / alpha_fraction)
|
||||
recovered_green = round((green - (1 - alpha_fraction) * key_green) / alpha_fraction)
|
||||
recovered_blue = round((blue - (1 - alpha_fraction) * key_blue) / alpha_fraction)
|
||||
result.append(
|
||||
(
|
||||
max(0, min(255, recovered_red)),
|
||||
max(0, min(255, recovered_green)),
|
||||
max(0, min(255, recovered_blue)),
|
||||
round(alpha_fraction * 255),
|
||||
)
|
||||
)
|
||||
|
||||
cleaned.putdata(result)
|
||||
return cleaned
|
||||
|
||||
|
||||
def trim_transparent(image: Image.Image) -> Image.Image:
|
||||
alpha = image.getchannel("A")
|
||||
bounds = alpha.getbbox()
|
||||
if bounds is None:
|
||||
raise ValueError("chroma removal left no visible artwork")
|
||||
return image.crop(bounds)
|
||||
|
||||
|
||||
def stretch_safe_center(
|
||||
image: Image.Image,
|
||||
*,
|
||||
output_size: tuple[int, int],
|
||||
cap_width: int,
|
||||
padding: int,
|
||||
) -> Image.Image:
|
||||
output_width, output_height = output_size
|
||||
inner_width = output_width - padding * 2
|
||||
inner_height = output_height - padding * 2
|
||||
if inner_width <= 0 or inner_height <= 0:
|
||||
raise ValueError("transparent padding leaves no drawable area")
|
||||
if cap_width <= 0 or cap_width * 2 >= image.width:
|
||||
raise ValueError(f"invalid capWidth {cap_width} for artwork width {image.width}")
|
||||
|
||||
target_cap_width = max(1, round(cap_width * inner_height / image.height))
|
||||
center_width = inner_width - target_cap_width * 2
|
||||
if center_width <= 0:
|
||||
raise ValueError("scaled caps leave no room for the stretch-safe center")
|
||||
|
||||
left = image.crop((0, 0, cap_width, image.height))
|
||||
center = image.crop((cap_width, 0, image.width - cap_width, image.height))
|
||||
right = image.crop((image.width - cap_width, 0, image.width, image.height))
|
||||
resampling = Image.Resampling.LANCZOS
|
||||
left = left.resize((target_cap_width, inner_height), resampling)
|
||||
center = center.resize((center_width, inner_height), resampling)
|
||||
right = right.resize((target_cap_width, inner_height), resampling)
|
||||
|
||||
output = Image.new("RGBA", output_size, (0, 0, 0, 0))
|
||||
output.alpha_composite(left, (padding, padding))
|
||||
output.alpha_composite(center, (padding + target_cap_width, padding))
|
||||
output.alpha_composite(right, (output_width - padding - target_cap_width, padding))
|
||||
return output
|
||||
|
||||
|
||||
def sanitize_output_edges(image: Image.Image) -> Image.Image:
|
||||
cleaned = []
|
||||
for red, green, blue, alpha in image.convert("RGBA").get_flattened_data():
|
||||
chroma_residue = alpha > 0 and green > 120 and green - max(red, blue) > 80
|
||||
light_fringe = 0 < alpha < 255 and red > 235 and green > 235 and blue > 235
|
||||
if alpha == 0 or chroma_residue or light_fringe:
|
||||
cleaned.append((0, 0, 0, 0))
|
||||
else:
|
||||
cleaned.append((red, green, blue, alpha))
|
||||
output = Image.new("RGBA", image.size, (0, 0, 0, 0))
|
||||
output.putdata(cleaned)
|
||||
return output
|
||||
|
||||
|
||||
def reduce_png_palette(image: Image.Image, colors: int = 192) -> Image.Image:
|
||||
alpha = image.getchannel("A")
|
||||
rgb = image.convert("RGB").quantize(
|
||||
colors=colors,
|
||||
method=Image.Quantize.MEDIANCUT,
|
||||
dither=Image.Dither.NONE,
|
||||
).convert("RGB")
|
||||
output = rgb.convert("RGBA")
|
||||
output.putalpha(alpha)
|
||||
return sanitize_output_edges(output)
|
||||
|
||||
|
||||
def parse_hex_color(value: str) -> tuple[int, int, int]:
|
||||
if len(value) != 7 or not value.startswith("#"):
|
||||
raise ValueError(f"invalid RGB hex color: {value}")
|
||||
return tuple(int(value[index:index + 2], 16) for index in (1, 3, 5))
|
||||
|
||||
|
||||
def build_asset(asset: dict, workspace: Path) -> None:
|
||||
processing = asset["processing"]
|
||||
alpha = asset["alpha"]
|
||||
pixels = asset["outputPixels"]
|
||||
source_path = workspace / asset["source"]
|
||||
output_path = workspace / asset["output"]
|
||||
|
||||
with Image.open(source_path) as source:
|
||||
cleaned = remove_chroma_background(
|
||||
source,
|
||||
parse_hex_color(processing["keyColor"]),
|
||||
int(processing["keyTolerance"]),
|
||||
)
|
||||
artwork = trim_transparent(cleaned)
|
||||
output = stretch_safe_center(
|
||||
artwork,
|
||||
output_size=(pixels["width"], pixels["height"]),
|
||||
cap_width=int(processing["capWidth"]),
|
||||
padding=int(alpha["transparentOuterPadding"]),
|
||||
)
|
||||
palette_colors = int(processing.get("paletteColors", 192))
|
||||
output = sanitize_output_edges(output)
|
||||
if processing.get("indexedPng"):
|
||||
output = output.quantize(
|
||||
colors=palette_colors,
|
||||
method=Image.Quantize.FASTOCTREE,
|
||||
dither=Image.Dither.NONE,
|
||||
)
|
||||
else:
|
||||
output = reduce_png_palette(output, colors=palette_colors)
|
||||
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
png_info = PngImagePlugin.PngInfo()
|
||||
png_info.add(b"sRGB", b"\x00")
|
||||
output.save(output_path, format="PNG", optimize=True, pnginfo=png_info)
|
||||
print(f"BUILT {asset['id']} -> {asset['output']}")
|
||||
|
||||
|
||||
def build_manifest(manifest_path: Path, workspace: Path) -> None:
|
||||
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||
for asset in manifest["assets"]:
|
||||
build_asset(asset, workspace)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("manifest", type=Path)
|
||||
parser.add_argument("--workspace", type=Path, required=True)
|
||||
args = parser.parse_args()
|
||||
build_manifest(args.manifest.resolve(), args.workspace.resolve())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,121 +0,0 @@
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
|
||||
const allowedAssetClasses = new Set([
|
||||
'code-native',
|
||||
'fixed-bitmap',
|
||||
'transparent-overlay',
|
||||
'nine-slice',
|
||||
'tile-texture'
|
||||
])
|
||||
|
||||
const requireObject = (value, label) => {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw new Error(`${label} must be an object`)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
const requireString = (value, label) => {
|
||||
if (typeof value !== 'string' || value.trim() === '') throw new Error(`${label} must be a non-empty string`)
|
||||
return value
|
||||
}
|
||||
|
||||
const requirePositiveNumber = (value, label) => {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) {
|
||||
throw new Error(`${label} must be a positive number`)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
const resolveWorkspacePath = (workspace, relativePath, label) => {
|
||||
requireString(relativePath, label)
|
||||
const absolutePath = path.resolve(workspace, relativePath)
|
||||
const relative = path.relative(workspace, absolutePath)
|
||||
if (relative.startsWith('..') || path.isAbsolute(relative)) {
|
||||
throw new Error(`${label} escapes workspace: ${relativePath}`)
|
||||
}
|
||||
return absolutePath
|
||||
}
|
||||
|
||||
const requireBoolean = (value, label) => {
|
||||
if (typeof value !== 'boolean') throw new Error(`${label} must be boolean`)
|
||||
return value
|
||||
}
|
||||
|
||||
export const validateManifest = (manifest, workspace) => {
|
||||
requireObject(manifest, 'manifest')
|
||||
if (manifest.schemaVersion !== 2) throw new Error('schemaVersion must be 2')
|
||||
requireString(manifest.page, 'page')
|
||||
requireObject(manifest.runtime, 'runtime')
|
||||
requireString(manifest.runtime.url, 'runtime.url')
|
||||
requirePositiveNumber(manifest.runtime.chromePort, 'runtime.chromePort')
|
||||
if (!Array.isArray(manifest.assets) || manifest.assets.length === 0) throw new Error('assets must be a non-empty array')
|
||||
|
||||
const ids = new Set()
|
||||
for (const asset of manifest.assets) {
|
||||
requireObject(asset, 'asset')
|
||||
const id = requireString(asset.id, 'asset.id')
|
||||
if (ids.has(id)) throw new Error(`duplicate asset id: ${id}`)
|
||||
ids.add(id)
|
||||
|
||||
if (!allowedAssetClasses.has(asset.assetClass)) throw new Error(`unsupported assetClass: ${asset.assetClass}`)
|
||||
resolveWorkspacePath(workspace, asset.source, `${id}.source`)
|
||||
resolveWorkspacePath(workspace, asset.output, `${id}.output`)
|
||||
|
||||
const logicalSlot = requireObject(asset.logicalSlot, `${id}.logicalSlot`)
|
||||
const outputPixels = requireObject(asset.outputPixels, `${id}.outputPixels`)
|
||||
const widthRpx = requirePositiveNumber(logicalSlot.widthRpx, `${id}.logicalSlot.widthRpx`)
|
||||
const heightRpx = requirePositiveNumber(logicalSlot.heightRpx, `${id}.logicalSlot.heightRpx`)
|
||||
const width = requirePositiveNumber(outputPixels.width, `${id}.outputPixels.width`)
|
||||
const height = requirePositiveNumber(outputPixels.height, `${id}.outputPixels.height`)
|
||||
|
||||
const render = requireObject(asset.render, `${id}.render`)
|
||||
requireString(render.scalePolicy, `${id}.render.scalePolicy`)
|
||||
requireString(render.uniMode, `${id}.render.uniMode`)
|
||||
requireBoolean(render.allowDistortion, `${id}.render.allowDistortion`)
|
||||
if (render.scalePolicy === 'uniform-only' && render.uniMode === 'scaleToFill') {
|
||||
throw new Error(`${id} uniform-only asset cannot use scaleToFill`)
|
||||
}
|
||||
const ratioDrift = Math.abs((width / height) / (widthRpx / heightRpx) - 1)
|
||||
if (asset.assetClass === 'fixed-bitmap' && ratioDrift > 0.005) {
|
||||
throw new Error(`${id} ratio drift exceeds 0.5%`)
|
||||
}
|
||||
|
||||
const alpha = requireObject(asset.alpha, `${id}.alpha`)
|
||||
requireBoolean(alpha.required, `${id}.alpha.required`)
|
||||
requirePositiveNumber(alpha.transparentOuterPadding, `${id}.alpha.transparentOuterPadding`)
|
||||
if (!Number.isInteger(alpha.cornerMaxAlpha) || alpha.cornerMaxAlpha < 0 || alpha.cornerMaxAlpha > 255) {
|
||||
throw new Error(`${id}.alpha.cornerMaxAlpha must be an integer from 0 to 255`)
|
||||
}
|
||||
|
||||
const edge = requireObject(asset.edge, `${id}.edge`)
|
||||
requireBoolean(edge.forbidChromaResidue, `${id}.edge.forbidChromaResidue`)
|
||||
requireBoolean(edge.forbidLightFringe, `${id}.edge.forbidLightFringe`)
|
||||
requireBoolean(edge.premultipliedAlphaCheck, `${id}.edge.premultipliedAlphaCheck`)
|
||||
|
||||
const quality = requireObject(asset.quality, `${id}.quality`)
|
||||
requirePositiveNumber(quality.maxBytes, `${id}.quality.maxBytes`)
|
||||
requireString(quality.colorSpace, `${id}.quality.colorSpace`)
|
||||
|
||||
const processing = requireObject(asset.processing, `${id}.processing`)
|
||||
requirePositiveNumber(processing.trim, `${id}.processing.trim`)
|
||||
requirePositiveNumber(processing.capWidth, `${id}.processing.capWidth`)
|
||||
|
||||
const runtime = requireObject(asset.runtime, `${id}.runtime`)
|
||||
requireString(runtime.selector, `${id}.runtime.selector`)
|
||||
requireString(runtime.imageSelector, `${id}.runtime.imageSelector`)
|
||||
|
||||
if (!Array.isArray(asset.consumers) || asset.consumers.length === 0) {
|
||||
throw new Error(`${id}.consumers must be a non-empty array`)
|
||||
}
|
||||
for (const consumer of asset.consumers) resolveWorkspacePath(workspace, consumer, `${id}.consumer`)
|
||||
}
|
||||
return manifest
|
||||
}
|
||||
|
||||
export const loadAndValidateManifest = async (filePath, workspace) => {
|
||||
const manifest = JSON.parse(await readFile(filePath, 'utf8'))
|
||||
return validateManifest(manifest, workspace)
|
||||
}
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { loadAndValidateManifest } from './manifest-v2.mjs'
|
||||
|
||||
const scriptDirectory = path.dirname(fileURLToPath(import.meta.url))
|
||||
const pipelineDirectory = path.resolve(scriptDirectory, '..')
|
||||
const workspace = path.resolve(pipelineDirectory, '..')
|
||||
const manifestPath = path.join(pipelineDirectory, 'manifests', 'a01-buttons-v2.json')
|
||||
const reportPath = path.join(pipelineDirectory, 'generated', 'a01-buttons-v2', 'quality-report.json')
|
||||
|
||||
await loadAndValidateManifest(manifestPath, workspace)
|
||||
|
||||
const localPython = path.join(pipelineDirectory, '.venv', 'Scripts', 'python.exe')
|
||||
const python = process.env.PYTHON || (fs.existsSync(localPython) ? localPython : 'python')
|
||||
|
||||
function runPython(script, args) {
|
||||
const result = spawnSync(python, [path.join(scriptDirectory, script), ...args], {
|
||||
cwd: workspace,
|
||||
encoding: 'utf8',
|
||||
stdio: 'inherit',
|
||||
})
|
||||
if (result.error) {
|
||||
throw new Error(`无法启动 Python(${python}):${result.error.message}`)
|
||||
}
|
||||
if (result.status !== 0) process.exit(result.status ?? 1)
|
||||
}
|
||||
|
||||
runPython('rebuild_a01_buttons.py', [manifestPath, '--workspace', workspace])
|
||||
runPython('verify-assets.py', [manifestPath, '--workspace', workspace, '--report', reportPath])
|
||||
@@ -1,89 +0,0 @@
|
||||
"""Rebuild A01 button skins with deterministic horizontal three-slice scaling."""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image, PngImagePlugin
|
||||
|
||||
|
||||
def rebuild_button(
|
||||
source_path: Path,
|
||||
output_path: Path,
|
||||
*,
|
||||
output_size: tuple[int, int],
|
||||
trim: int,
|
||||
padding: int,
|
||||
cap_width: int,
|
||||
) -> None:
|
||||
"""Trim contaminated edges, preserve both caps, and stretch only the center."""
|
||||
with Image.open(source_path) as opened:
|
||||
source = opened.convert("RGBA")
|
||||
|
||||
if trim < 0 or trim * 2 >= min(source.size):
|
||||
raise ValueError(f"invalid trim {trim} for source size {source.size}")
|
||||
if trim:
|
||||
source = source.crop((trim, trim, source.width - trim, source.height - trim))
|
||||
|
||||
if cap_width <= 0 or cap_width * 2 >= source.width:
|
||||
raise ValueError(f"invalid capWidth {cap_width} for cropped width {source.width}")
|
||||
|
||||
output_width, output_height = output_size
|
||||
inner_width = output_width - padding * 2
|
||||
inner_height = output_height - padding * 2
|
||||
if inner_width <= 0 or inner_height <= 0:
|
||||
raise ValueError(f"padding {padding} leaves no drawable area in {output_size}")
|
||||
|
||||
target_cap_width = max(1, round(cap_width * inner_height / source.height))
|
||||
if target_cap_width * 2 >= inner_width:
|
||||
raise ValueError("scaled caps leave no room for the center slice")
|
||||
|
||||
left = source.crop((0, 0, cap_width, source.height))
|
||||
center = source.crop((cap_width, 0, source.width - cap_width, source.height))
|
||||
right = source.crop((source.width - cap_width, 0, source.width, source.height))
|
||||
|
||||
resampling = Image.Resampling.LANCZOS
|
||||
left = left.resize((target_cap_width, inner_height), resampling)
|
||||
center = center.resize((inner_width - target_cap_width * 2, inner_height), resampling)
|
||||
right = right.resize((target_cap_width, inner_height), resampling)
|
||||
|
||||
output = Image.new("RGBA", output_size, (0, 0, 0, 0))
|
||||
output.alpha_composite(left, (padding, padding))
|
||||
output.alpha_composite(center, (padding + target_cap_width, padding))
|
||||
output.alpha_composite(right, (output_width - padding - target_cap_width, padding))
|
||||
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
png_info = PngImagePlugin.PngInfo()
|
||||
png_info.add(b"sRGB", b"\x00")
|
||||
output.save(output_path, format="PNG", optimize=True, pnginfo=png_info)
|
||||
|
||||
|
||||
def rebuild_manifest(manifest_path: Path, workspace_root: Path) -> None:
|
||||
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||
for asset in manifest["assets"]:
|
||||
processing = asset["processing"]
|
||||
alpha = asset["alpha"]
|
||||
pixels = asset["outputPixels"]
|
||||
source = workspace_root / asset["source"]
|
||||
output = workspace_root / asset["output"]
|
||||
rebuild_button(
|
||||
source,
|
||||
output,
|
||||
output_size=(pixels["width"], pixels["height"]),
|
||||
trim=processing["trim"],
|
||||
padding=alpha["transparentOuterPadding"],
|
||||
cap_width=processing["capWidth"],
|
||||
)
|
||||
print(f"REBUILT {asset['id']} -> {asset['output']}")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("manifest", type=Path)
|
||||
parser.add_argument("--workspace", type=Path, required=True)
|
||||
args = parser.parse_args()
|
||||
rebuild_manifest(args.manifest.resolve(), args.workspace.resolve())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -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
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { loadAndValidateManifest } from './manifest-v2.mjs'
|
||||
|
||||
const scriptDirectory = path.dirname(fileURLToPath(import.meta.url))
|
||||
const pipelineDirectory = path.resolve(scriptDirectory, '..')
|
||||
const workspace = path.resolve(pipelineDirectory, '..')
|
||||
const manifestArgument = process.argv[2]
|
||||
|
||||
if (!manifestArgument) throw new Error('Usage: node validate-manifest-v2.mjs <manifest>')
|
||||
|
||||
const manifestPath = path.resolve(pipelineDirectory, manifestArgument)
|
||||
await loadAndValidateManifest(manifestPath, workspace)
|
||||
process.stdout.write(`MANIFEST-V2 PASS ${path.relative(workspace, manifestPath).replaceAll('\\', '/')}\n`)
|
||||
|
||||
@@ -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`)
|
||||
@@ -1,22 +0,0 @@
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { loadAndValidateManifest } from './manifest-v2.mjs'
|
||||
|
||||
const scriptDirectory = path.dirname(fileURLToPath(import.meta.url))
|
||||
const pipelineDirectory = path.resolve(scriptDirectory, '..')
|
||||
const workspace = path.resolve(pipelineDirectory, '..')
|
||||
const manifestPath = path.join(pipelineDirectory, 'manifests', 'a01-scroll-skins-v3.json')
|
||||
const reportPath = path.join(pipelineDirectory, 'generated', 'a01-scroll-skins-v3', 'quality-report.json')
|
||||
const localPython = path.join(pipelineDirectory, '.venv', 'Scripts', 'python.exe')
|
||||
const python = process.env.PYTHON || (fs.existsSync(localPython) ? localPython : 'python')
|
||||
|
||||
await loadAndValidateManifest(manifestPath, workspace)
|
||||
const result = spawnSync(
|
||||
python,
|
||||
[path.join(scriptDirectory, 'verify-assets.py'), manifestPath, '--workspace', workspace, '--report', reportPath],
|
||||
{ cwd: workspace, encoding: 'utf8', stdio: 'inherit' }
|
||||
)
|
||||
if (result.error) throw new Error(`无法启动 Python(${python}):${result.error.message}`)
|
||||
if (result.status !== 0) process.exit(result.status ?? 1)
|
||||
@@ -1,24 +0,0 @@
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { loadAndValidateManifest } from './manifest-v2.mjs'
|
||||
|
||||
const scriptDirectory = path.dirname(fileURLToPath(import.meta.url))
|
||||
const pipelineDirectory = path.resolve(scriptDirectory, '..')
|
||||
const workspace = path.resolve(pipelineDirectory, '..')
|
||||
const manifestPath = path.join(pipelineDirectory, 'manifests', 'a01-buttons-v2.json')
|
||||
const reportPath = path.join(pipelineDirectory, 'generated', 'a01-buttons-v2', 'quality-report.json')
|
||||
|
||||
await loadAndValidateManifest(manifestPath, workspace)
|
||||
|
||||
const localPython = path.join(pipelineDirectory, '.venv', 'Scripts', 'python.exe')
|
||||
const python = process.env.PYTHON || (fs.existsSync(localPython) ? localPython : 'python')
|
||||
const result = spawnSync(
|
||||
python,
|
||||
[path.join(scriptDirectory, 'verify-assets.py'), manifestPath, '--workspace', workspace, '--report', reportPath],
|
||||
{ cwd: workspace, encoding: 'utf8', stdio: 'inherit' }
|
||||
)
|
||||
|
||||
if (result.error) throw new Error(`无法启动 Python(${python}):${result.error.message}`)
|
||||
if (result.status !== 0) process.exit(result.status ?? 1)
|
||||
@@ -1,47 +0,0 @@
|
||||
"""Verify generated assets declared in a manifest v2 file."""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from asset_quality import analyze_asset
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("manifest", type=Path)
|
||||
parser.add_argument("--workspace", type=Path, required=True)
|
||||
parser.add_argument("--report", type=Path, required=True)
|
||||
args = parser.parse_args()
|
||||
|
||||
manifest = json.loads(args.manifest.read_text(encoding="utf-8"))
|
||||
reports = []
|
||||
failed = False
|
||||
for asset in manifest["assets"]:
|
||||
output = args.workspace / asset["output"]
|
||||
if not output.is_file():
|
||||
report = {
|
||||
"id": asset["id"],
|
||||
"path": str(output),
|
||||
"errors": ["output file is missing"],
|
||||
"warnings": [],
|
||||
"metrics": {},
|
||||
}
|
||||
else:
|
||||
report = analyze_asset(output, asset)
|
||||
reports.append(report)
|
||||
if report["errors"]:
|
||||
failed = True
|
||||
print(f"ASSET-QUALITY FAIL {asset['id']}: {'; '.join(report['errors'])}")
|
||||
else:
|
||||
print(f"ASSET-QUALITY PASS {asset['id']}")
|
||||
|
||||
args.report.parent.mkdir(parents=True, exist_ok=True)
|
||||
args.report.write_text(json.dumps(reports, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
if failed:
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,47 +0,0 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const testDirectory = path.dirname(fileURLToPath(import.meta.url))
|
||||
const workspace = path.resolve(testDirectory, '..', '..')
|
||||
const manifestPath = path.join(workspace, 'design-pipeline', 'manifests', 'g01-background-candidates.json')
|
||||
const wrapperPath = path.join(workspace, 'design-pipeline', 'scripts', 'build-g01-backgrounds.mjs')
|
||||
|
||||
test('G01 background candidate manifest preserves four portable masters and selects the approved long background', async () => {
|
||||
const manifest = JSON.parse(await readFile(manifestPath, 'utf8'))
|
||||
assert.equal(manifest.schemaVersion, 1)
|
||||
assert.equal(manifest.page, 'G01')
|
||||
assert.equal(manifest.status, 'long-flagship-selected-for-genealogy-module')
|
||||
assert.equal(manifest.selectedCandidateId, 'g01-list-background-long-flagship')
|
||||
assert.equal(manifest.runtimeOutput, 'static/assets/modules/genealogy/opaque/genealogy-page-background-long.png')
|
||||
assert.equal(manifest.candidates.length, 4)
|
||||
|
||||
const ids = new Set()
|
||||
for (const candidate of manifest.candidates) {
|
||||
assert.ok(!ids.has(candidate.id), `duplicate candidate id: ${candidate.id}`)
|
||||
ids.add(candidate.id)
|
||||
assert.match(candidate.prompt, /\d+x\d+/)
|
||||
assert.ok(candidate.sourcePixels.width > 0 && candidate.sourcePixels.height > 0)
|
||||
assert.ok(['chroma-key', 'opaque-paper', 'opaque-paper-resize'].includes(candidate.processingMode))
|
||||
|
||||
for (const key of ['master', 'generatedOutput']) {
|
||||
const absolute = path.resolve(workspace, candidate[key])
|
||||
const relative = path.relative(workspace, absolute)
|
||||
assert.ok(!relative.startsWith('..') && !path.isAbsolute(relative), `${key} escapes workspace`)
|
||||
}
|
||||
}
|
||||
|
||||
const selected = manifest.candidates.find(({ id }) => id === manifest.selectedCandidateId)
|
||||
assert.deepEqual(selected.sourcePixels, { width: 1536, height: 3840 })
|
||||
assert.deepEqual(selected.outputPixels, { width: 1440, height: 3600 })
|
||||
assert.equal(selected.processingMode, 'opaque-paper-resize')
|
||||
})
|
||||
|
||||
test('G01 Node wrapper delegates pixel decoding to locked Pillow without Sharp', async () => {
|
||||
const wrapper = await readFile(wrapperPath, 'utf8')
|
||||
assert.doesNotMatch(wrapper, /from ['"]sharp['"]/)
|
||||
assert.match(wrapper, /build_g01_backgrounds\.py/)
|
||||
assert.match(wrapper, /candidates\?\.length !== 4/)
|
||||
})
|
||||
@@ -1,72 +0,0 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { validateManifest } from '../scripts/manifest-v2.mjs'
|
||||
|
||||
const testDirectory = path.dirname(fileURLToPath(import.meta.url))
|
||||
const workspace = path.resolve(testDirectory, '..', '..')
|
||||
|
||||
const validAsset = () => ({
|
||||
id: 'a01-primary-button-v2',
|
||||
assetClass: 'fixed-bitmap',
|
||||
source: 'static/assets/foundation/opaque/a01-primary-button.png',
|
||||
output: 'static/assets/foundation/transparent/a01-primary-button-v2.png',
|
||||
logicalSlot: { widthRpx: 622, heightRpx: 92 },
|
||||
outputPixels: { width: 1866, height: 276 },
|
||||
render: { scalePolicy: 'uniform-only', uniMode: 'aspectFit', allowDistortion: false },
|
||||
alpha: { required: true, transparentOuterPadding: 12, cornerMaxAlpha: 0 },
|
||||
edge: { forbidChromaResidue: true, forbidLightFringe: true, premultipliedAlphaCheck: true },
|
||||
quality: { maxBytes: 500000, symmetry: 'horizontal', colorSpace: 'sRGB' },
|
||||
processing: { trim: 8, capWidth: 180 },
|
||||
runtime: { selector: '.login-submit', imageSelector: '.button-skin img' },
|
||||
consumers: ['pages/auth/a01-entry.vue']
|
||||
})
|
||||
|
||||
const validManifest = () => ({
|
||||
schemaVersion: 2,
|
||||
page: 'A01',
|
||||
runtime: { url: 'http://localhost:5173/#/pages/auth/a01-entry', chromePort: 9222 },
|
||||
assets: [validAsset()]
|
||||
})
|
||||
|
||||
test('accepts a complete manifest v2', () => {
|
||||
const manifest = validManifest()
|
||||
assert.equal(validateManifest(manifest, workspace), manifest)
|
||||
})
|
||||
|
||||
test('rejects duplicate asset ids', () => {
|
||||
const manifest = validManifest()
|
||||
manifest.assets.push(validAsset())
|
||||
assert.throws(() => validateManifest(manifest, workspace), /duplicate asset id/i)
|
||||
})
|
||||
|
||||
test('rejects paths that escape the workspace', () => {
|
||||
const manifest = validManifest()
|
||||
manifest.assets[0].output = '../outside.png'
|
||||
assert.throws(() => validateManifest(manifest, workspace), /escapes workspace/i)
|
||||
})
|
||||
|
||||
test('rejects a fixed bitmap whose output ratio differs from its slot', () => {
|
||||
const manifest = validManifest()
|
||||
manifest.assets[0].outputPixels.height = 300
|
||||
assert.throws(() => validateManifest(manifest, workspace), /ratio drift/i)
|
||||
})
|
||||
|
||||
test('rejects scaleToFill for uniform-only assets', () => {
|
||||
const manifest = validManifest()
|
||||
manifest.assets[0].render.uniMode = 'scaleToFill'
|
||||
assert.throws(() => validateManifest(manifest, workspace), /scaleToFill/i)
|
||||
})
|
||||
|
||||
test('rejects unknown asset classes', () => {
|
||||
const manifest = validManifest()
|
||||
manifest.assets[0].assetClass = 'mystery'
|
||||
assert.throws(() => validateManifest(manifest, workspace), /assetClass/i)
|
||||
})
|
||||
|
||||
test('rejects assets without consumers', () => {
|
||||
const manifest = validManifest()
|
||||
manifest.assets[0].consumers = []
|
||||
assert.throws(() => validateManifest(manifest, workspace), /consumers/i)
|
||||
})
|
||||
@@ -1,30 +0,0 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { validateManifest } from '../scripts/manifest-v2.mjs'
|
||||
|
||||
const testDirectory = path.dirname(fileURLToPath(import.meta.url))
|
||||
const pipelineDirectory = path.resolve(testDirectory, '..')
|
||||
const workspace = path.resolve(pipelineDirectory, '..')
|
||||
const manifestPath = path.join(pipelineDirectory, 'manifests', 'a01-scroll-skins-v3.json')
|
||||
|
||||
test('declares the approved A01 scroll-skin family', async () => {
|
||||
const manifest = JSON.parse(await readFile(manifestPath, 'utf8'))
|
||||
validateManifest(manifest, workspace)
|
||||
|
||||
assert.equal(manifest.assets.length, 4)
|
||||
assert.deepEqual(
|
||||
manifest.assets.map(({ id }) => id),
|
||||
['a01-scroll-primary-v3', 'a01-scroll-secondary-v3', 'a01-scroll-toast-v3', 'a01-scroll-dialog-v3']
|
||||
)
|
||||
assert.deepEqual(
|
||||
manifest.assets.map(({ outputPixels }) => [outputPixels.width, outputPixels.height]),
|
||||
[[1866, 276], [1866, 300], [1770, 246], [1860, 1560]]
|
||||
)
|
||||
assert.equal(manifest.assets[2].assetClass, 'nine-slice')
|
||||
assert.equal(manifest.assets[2].render.scalePolicy, 'nine-slice')
|
||||
assert.equal(manifest.assets[3].render.uniMode, 'aspectFit')
|
||||
assert(manifest.assets.every(({ consumers }) => consumers.includes('pages/auth/a01-entry.vue')))
|
||||
})
|
||||
@@ -1,100 +0,0 @@
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image
|
||||
|
||||
SCRIPTS = Path(__file__).resolve().parents[1] / "scripts"
|
||||
sys.path.insert(0, str(SCRIPTS))
|
||||
|
||||
from asset_quality import analyze_asset
|
||||
|
||||
|
||||
class AssetQualityTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.directory = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.directory.cleanup)
|
||||
self.root = Path(self.directory.name)
|
||||
|
||||
def spec(self):
|
||||
return {
|
||||
"id": "test-button",
|
||||
"outputPixels": {"width": 32, "height": 16},
|
||||
"alpha": {"required": True, "transparentOuterPadding": 2, "cornerMaxAlpha": 0},
|
||||
"edge": {
|
||||
"forbidChromaResidue": True,
|
||||
"forbidLightFringe": True,
|
||||
"premultipliedAlphaCheck": True,
|
||||
},
|
||||
"quality": {"maxBytes": 10000, "colorSpace": "sRGB"},
|
||||
}
|
||||
|
||||
def clean_image(self):
|
||||
image = Image.new("RGBA", (32, 16), (0, 0, 0, 0))
|
||||
for y in range(2, 14):
|
||||
for x in range(2, 30):
|
||||
image.putpixel((x, y), (150, 20, 12, 255))
|
||||
return image
|
||||
|
||||
def save(self, image, name="asset.png"):
|
||||
path = self.root / name
|
||||
image.save(path, format="PNG")
|
||||
return path
|
||||
|
||||
def error_text(self, report):
|
||||
return " ".join(report["errors"])
|
||||
|
||||
def test_accepts_clean_rgba_asset(self):
|
||||
report = analyze_asset(self.save(self.clean_image()), self.spec())
|
||||
self.assertEqual([], report["errors"])
|
||||
self.assertEqual(32, report["width"])
|
||||
self.assertEqual(16, report["height"])
|
||||
|
||||
def test_accepts_indexed_png_with_real_transparency(self):
|
||||
indexed = self.clean_image().quantize(
|
||||
colors=16,
|
||||
method=Image.Quantize.FASTOCTREE,
|
||||
dither=Image.Dither.NONE,
|
||||
)
|
||||
report = analyze_asset(self.save(indexed), self.spec())
|
||||
self.assertEqual([], report["errors"])
|
||||
self.assertEqual("P", report["mode"])
|
||||
|
||||
def test_rejects_wrong_dimensions(self):
|
||||
report = analyze_asset(self.save(Image.new("RGBA", (31, 16), (0, 0, 0, 0))), self.spec())
|
||||
self.assertIn("dimensions", self.error_text(report))
|
||||
|
||||
def test_rejects_opaque_outer_padding(self):
|
||||
image = self.clean_image()
|
||||
image.putpixel((0, 0), (120, 30, 20, 255))
|
||||
report = analyze_asset(self.save(image), self.spec())
|
||||
self.assertIn("outer padding", self.error_text(report))
|
||||
|
||||
def test_rejects_visible_green_residue(self):
|
||||
image = self.clean_image()
|
||||
image.putpixel((16, 8), (0, 255, 0, 255))
|
||||
report = analyze_asset(self.save(image), self.spec())
|
||||
self.assertIn("chroma residue", self.error_text(report))
|
||||
|
||||
def test_rejects_light_partially_transparent_fringe(self):
|
||||
image = self.clean_image()
|
||||
image.putpixel((2, 8), (250, 250, 250, 128))
|
||||
report = analyze_asset(self.save(image), self.spec())
|
||||
self.assertIn("light fringe", self.error_text(report))
|
||||
|
||||
def test_rejects_hidden_rgb_in_fully_transparent_pixels(self):
|
||||
image = self.clean_image()
|
||||
image.putpixel((0, 0), (255, 255, 255, 0))
|
||||
report = analyze_asset(self.save(image), self.spec())
|
||||
self.assertIn("transparent RGB", self.error_text(report))
|
||||
|
||||
def test_rejects_file_over_max_bytes(self):
|
||||
spec = self.spec()
|
||||
spec["quality"]["maxBytes"] = 8
|
||||
report = analyze_asset(self.save(self.clean_image()), spec)
|
||||
self.assertIn("maxBytes", self.error_text(report))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,71 +0,0 @@
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image
|
||||
|
||||
SCRIPTS = Path(__file__).resolve().parents[1] / "scripts"
|
||||
sys.path.insert(0, str(SCRIPTS))
|
||||
|
||||
from build_g01_backgrounds import process_chroma_image, process_opaque_image, save_png
|
||||
|
||||
|
||||
class G01BackgroundBuilderTests(unittest.TestCase):
|
||||
def config(self):
|
||||
return {
|
||||
"dominanceStart": 30,
|
||||
"dominanceEnd": 220,
|
||||
"despillAllowance": 10,
|
||||
}
|
||||
|
||||
def test_pure_green_becomes_clean_transparency(self):
|
||||
source = Image.new("RGB", (2, 1), (0, 255, 0))
|
||||
output = process_chroma_image(source, self.config())
|
||||
self.assertEqual((0, 0, 0, 0), output.getpixel((0, 0)))
|
||||
|
||||
def test_warm_artwork_remains_opaque(self):
|
||||
source = Image.new("RGB", (1, 1), (210, 190, 150))
|
||||
output = process_chroma_image(source, self.config())
|
||||
self.assertEqual((210, 190, 150, 255), output.getpixel((0, 0)))
|
||||
|
||||
def test_antialiased_green_edge_is_translucent_and_despilled(self):
|
||||
source = Image.new("RGB", (1, 1), (100, 200, 90))
|
||||
output = process_chroma_image(source, self.config())
|
||||
red, green, blue, alpha = output.getpixel((0, 0))
|
||||
self.assertGreater(alpha, 0)
|
||||
self.assertLess(alpha, 255)
|
||||
self.assertLessEqual(green, max(red, blue) + 10)
|
||||
|
||||
def test_opaque_master_is_normalized_to_rgba_without_transparency(self):
|
||||
source = Image.new("RGB", (1, 1), (240, 235, 220))
|
||||
output = process_opaque_image(source)
|
||||
self.assertEqual("RGBA", output.mode)
|
||||
self.assertEqual((240, 235, 220, 255), output.getpixel((0, 0)))
|
||||
|
||||
def test_opaque_master_can_be_resized_to_locked_runtime_dimensions(self):
|
||||
source = Image.new("RGB", (2, 3), (240, 235, 220))
|
||||
output = process_opaque_image(source, (4, 6))
|
||||
self.assertEqual((4, 6), output.size)
|
||||
self.assertEqual("RGBA", output.mode)
|
||||
self.assertEqual((240, 235, 220, 255), output.getpixel((3, 5)))
|
||||
|
||||
def test_saved_png_declares_srgb_color_profile(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
path = Path(directory) / "asset.png"
|
||||
save_png(Image.new("RGBA", (1, 1), (240, 235, 220, 255)), path)
|
||||
with Image.open(path) as opened:
|
||||
self.assertIn("srgb", opened.info)
|
||||
|
||||
def test_saved_png_bytes_are_deterministic(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
first = Path(directory) / "first.png"
|
||||
second = Path(directory) / "second.png"
|
||||
image = Image.new("RGBA", (2, 2), (240, 235, 220, 255))
|
||||
save_png(image, first)
|
||||
save_png(image, second)
|
||||
self.assertEqual(first.read_bytes(), second.read_bytes())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,92 +0,0 @@
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image
|
||||
|
||||
SCRIPTS = Path(__file__).resolve().parents[1] / "scripts"
|
||||
sys.path.insert(0, str(SCRIPTS))
|
||||
|
||||
from build_scroll_skins import build_asset, remove_chroma_background, sanitize_output_edges, stretch_safe_center
|
||||
|
||||
|
||||
class BuildScrollSkinsTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.directory = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.directory.cleanup)
|
||||
self.root = Path(self.directory.name)
|
||||
|
||||
def test_chroma_background_becomes_transparent_black(self):
|
||||
image = Image.new("RGB", (4, 2), (0, 255, 0))
|
||||
image.putpixel((1, 0), (180, 30, 20))
|
||||
|
||||
cleaned = remove_chroma_background(image, (0, 255, 0), tolerance=80)
|
||||
|
||||
self.assertEqual((0, 0, 0, 0), cleaned.getpixel((0, 0)))
|
||||
self.assertEqual((180, 30, 20, 255), cleaned.getpixel((1, 0)))
|
||||
|
||||
def test_safe_center_stretch_preserves_caps_and_exact_size(self):
|
||||
image = Image.new("RGBA", (30, 10), (150, 20, 10, 255))
|
||||
for y in range(10):
|
||||
for x in range(5):
|
||||
image.putpixel((x, y), (220, 170, 40, 255))
|
||||
image.putpixel((29 - x, y), (220, 170, 40, 255))
|
||||
|
||||
output = stretch_safe_center(
|
||||
image,
|
||||
output_size=(60, 20),
|
||||
cap_width=5,
|
||||
padding=2,
|
||||
)
|
||||
|
||||
self.assertEqual((60, 20), output.size)
|
||||
self.assertEqual((0, 0, 0, 0), output.getpixel((0, 0)))
|
||||
self.assertEqual((220, 170, 40, 255), output.getpixel((3, 10)))
|
||||
self.assertEqual((150, 20, 10, 255), output.getpixel((30, 10)))
|
||||
self.assertEqual((220, 170, 40, 255), output.getpixel((56, 10)))
|
||||
|
||||
def test_sanitizes_only_forbidden_edge_artifacts(self):
|
||||
image = Image.new("RGBA", (3, 1), (248, 240, 220, 255))
|
||||
image.putpixel((0, 0), (0, 255, 0, 128))
|
||||
image.putpixel((1, 0), (250, 250, 250, 96))
|
||||
|
||||
cleaned = sanitize_output_edges(image)
|
||||
|
||||
self.assertEqual((0, 0, 0, 0), cleaned.getpixel((0, 0)))
|
||||
self.assertEqual((0, 0, 0, 0), cleaned.getpixel((1, 0)))
|
||||
self.assertEqual((248, 240, 220, 255), cleaned.getpixel((2, 0)))
|
||||
|
||||
def test_build_asset_honors_declared_palette_size(self):
|
||||
source = Image.new("RGB", (32, 16), (0, 255, 0))
|
||||
for y in range(1, 15):
|
||||
for x in range(1, 31):
|
||||
source.putpixel((x, y), (120 + (x % 30) * 4, 20 + y, 10))
|
||||
source_path = self.root / "source.png"
|
||||
output_path = self.root / "output.png"
|
||||
source.save(source_path)
|
||||
asset = {
|
||||
"id": "palette-test",
|
||||
"source": "source.png",
|
||||
"output": "output.png",
|
||||
"outputPixels": {"width": 60, "height": 30},
|
||||
"alpha": {"transparentOuterPadding": 1},
|
||||
"processing": {
|
||||
"capWidth": 4,
|
||||
"keyColor": "#00FF00",
|
||||
"keyTolerance": 80,
|
||||
"paletteColors": 8,
|
||||
},
|
||||
}
|
||||
|
||||
build_asset(asset, self.root)
|
||||
|
||||
with Image.open(output_path) as output:
|
||||
visible_colors = {
|
||||
pixel[:3] for pixel in output.convert("RGBA").get_flattened_data() if pixel[3] > 0
|
||||
}
|
||||
self.assertLessEqual(len(visible_colors), 8)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,49 +0,0 @@
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
SCRIPTS = Path(__file__).resolve().parents[1] / "scripts"
|
||||
sys.path.insert(0, str(SCRIPTS))
|
||||
|
||||
from rebuild_a01_buttons import rebuild_button
|
||||
|
||||
|
||||
class RebuildA01ButtonTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.directory = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.directory.cleanup)
|
||||
self.root = Path(self.directory.name)
|
||||
|
||||
def make_source(self):
|
||||
image = Image.new("RGBA", (60, 30), (0, 255, 0, 255))
|
||||
draw = ImageDraw.Draw(image)
|
||||
draw.rectangle((4, 4, 55, 25), fill=(150, 20, 12, 255))
|
||||
draw.rectangle((4, 4, 13, 25), fill=(210, 160, 40, 255))
|
||||
draw.rectangle((46, 4, 55, 25), fill=(210, 160, 40, 255))
|
||||
return image
|
||||
|
||||
def test_rebuilds_three_slice_into_clean_transparent_canvas(self):
|
||||
source = self.root / "source.png"
|
||||
output = self.root / "output.png"
|
||||
self.make_source().save(source)
|
||||
|
||||
rebuild_button(source, output, output_size=(180, 60), trim=4, padding=6, cap_width=10)
|
||||
|
||||
with Image.open(output) as image:
|
||||
self.assertEqual("RGBA", image.mode)
|
||||
self.assertEqual((180, 60), image.size)
|
||||
self.assertEqual((0, 0, 0, 0), image.getpixel((0, 0)))
|
||||
self.assertEqual((0, 0, 0, 0), image.getpixel((179, 59)))
|
||||
visible_pixels = [pixel for pixel in image.get_flattened_data() if pixel[3] > 8]
|
||||
self.assertFalse(
|
||||
any(green > 120 and green - max(red, blue) > 80 for red, green, blue, green_alpha in visible_pixels)
|
||||
)
|
||||
self.assertGreater(image.getpixel((7, 30))[0], image.getpixel((90, 30))[0])
|
||||
self.assertGreater(image.getpixel((172, 30))[0], image.getpixel((90, 30))[0])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
-1353
File diff suppressed because it is too large
Load Diff
@@ -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。
|
||||
- 暂不实现为可用功能:邀请码、视频与多管理员分级权限;接口文档尚未提供对应端点。
|
||||
@@ -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,45 +0,0 @@
|
||||
# 全活动页面业务所有权审计(2026-07-20)
|
||||
|
||||
## 结论
|
||||
|
||||
52 条活动路由已经完成业务所有权收敛。活动页面不再引用 `ModulePage.vue` 或 `page-catalog.js`;共享层只保留按钮、弹层、Toast、加载、页头、背景和视觉卡片等原语。每个路由自行维护本页的模拟数据、状态、校验、动作及去向,仍未连接接口。
|
||||
|
||||
`ModulePage.vue`、`page-catalog.js` 和 `TreeMemberForm.vue` 按“不删除既有源码”的约束保留为历史文件,但活动路由不再消费这些通用业务母版。
|
||||
|
||||
## 五个方案与选择
|
||||
|
||||
1. 继续使用单一通用业务母版:改动最少,但页面语义、状态和真实入口继续失真,不采用。
|
||||
2. 扩大 `page-catalog.js` 配置:能增加字段,却会把校验、状态机和导航也塞入配置,不采用。
|
||||
3. 每个模块保留一个大型业务母版:比全局母版稍好,但 F、R、N、M 内部页面仍然职责不同,不采用。
|
||||
4. 建立无头 schema 渲染器:扩展性高,但会引入当前项目不需要的抽象和维护成本,不采用。
|
||||
5. 共享视觉原语、路由拥有业务内容:既保持统一国风视觉,又允许列表、详情、编辑器、时间线、消息、安全和服务页表达真实任务,采用。
|
||||
|
||||
## 本轮收敛范围
|
||||
|
||||
- F:动态详情、谱文列表/详情/编辑、相册列表/详情/上传、视频状态及 F01 八个真实入口形成闭环。
|
||||
- R:人物、贺礼、礼仪、成长、人生事、备忘、功德分别拥有专属数据结构、字段、状态和动作。
|
||||
- N/M:消息卡进入消息详情;资料、安全、密码、手机号、帮助、反馈、推广、订单和关于均为独立页面。
|
||||
- G:家谱上下文、成员身份、加入申请来源、驳回/撤回再申请、设置草稿和字辈容量由路由参数与数据决定。
|
||||
- T:T01 根据 `generation`/`parentId` 计算树布局;T03—T08 按人物、关系及权限状态工作,不再用演示标签切状态。
|
||||
- 共享组件:按钮改为原生可聚焦按钮;弹层补齐对话框语义、焦点和 Escape;Toast/Loading 补齐 live region 与减少动态效果;页头返回支持深链兜底。
|
||||
|
||||
## 数据与布局边界
|
||||
|
||||
- 普通列表和卡片由数据循环与自然高度撑开,不依赖固定条数或绝对坐标。
|
||||
- T01 的布局尺寸从成员关系计算,测试覆盖 10 世代、每代 12 人的 120 节点压力数据。
|
||||
- 容量合同覆盖 50 条列表、50 条弹层、30 项媒体、三倍长文本和 1.3 倍字号;103 个保留风险均精确到文件、选择器和风险类型。
|
||||
- 定位合同扫描所有 `position` 值(包括 `relative`);必要定位精确白名单之外无违规,也无失效白名单。
|
||||
|
||||
## 验证证据
|
||||
|
||||
- 52 条活动路由及 2 个共享组件 Vite 转换:54/54。
|
||||
- PowerShell 合同:119 项中 118 项通过;唯一失败为既有基础资产候选式命名。A01 预览已从可复现管线重新生成,G06 一致性合同已改读长期 handoff 证据,不再依赖已清理的 runtime 目录。
|
||||
- 浏览器运行时 smoke:排除会写回已清理长期目录的旧 T07 baseline 后,26/26 通过。
|
||||
- 52 个活动页面均在 360×800 捕获当前图到 `%TEMP%`,按 A/G/T、F、R、N/M 生成联系表,并与现有同模块长期候选图复核;未发现横向溢出、明显裁切、错误复用母版或不可达主动作。
|
||||
- 四档视口:320×568、360×640、360×800、412×915;覆盖模块响应式、数据压力和主要业务状态。
|
||||
|
||||
## 明确边界
|
||||
|
||||
- 本轮结论是 H5 本地模拟数据阶段的页面实现与内部审核完成,不代表接口、持久化、上传权限、微信能力、Android/HBuilderX、软键盘或真机性能完成。
|
||||
- F01、T01、A05 及大量业务页发生可见变化;自动审计不能替用户维持或新增 `[x]`。这些页面保持“待用户复核”,只有用户再次明确说“通过”才更新视觉冻结状态。
|
||||
- `foundation-asset-audit` 仍未通过,未放宽规则或删除实际资产。`repository-handoff-size-contract` 通过补齐可再生成目录的忽略规则后已真实通过(35 个长期截图、19.51MB、52 个索引链接),没有删除长期资料或放宽阈值。
|
||||
@@ -1,58 +0,0 @@
|
||||
# 全项目数据驱动布局审计(2026-07-20)
|
||||
|
||||
## 审计基线
|
||||
|
||||
- 权威基线:`main` / `01d246c 保存视觉审核与文档流迁移进度`。
|
||||
- 范围:`pages/`、`components/`、活动路由与封存 A06;当前只验证 H5,不接接口,不宣称 Android 完成。
|
||||
- 首次容量合同发现 279 项待解释风险。修正“固定网格容量”规则后为 276 项:普通 `repeat(2/3/4, ...)` 是响应式列数,不等于数据容量上限;只有 12 列及以上的固定重复网格才按固定容量画布拦截。
|
||||
- 当前截图证据:G01 切换家谱弹层在 412×915、6 条数据下可以自然增高并滚动,作为弹层容量参考;这不代表其他页面已经通过。
|
||||
|
||||
## 统一五方案决策
|
||||
|
||||
每个确认问题均评估以下五种方案:
|
||||
|
||||
1. 扩大固定尺寸:改动小,但只推迟溢出,淘汰。
|
||||
2. 增加响应式断点:只解决设备宽高差异,不能解决未知数据量;仅作补充。
|
||||
3. `max-height` 加内部滚动:适合弹层、独立列表和结构化画布,不用于普通内容卡片。
|
||||
4. 正常文档流自然撑高:适合普通页面、列表卡、表单、状态说明和长文案,作为默认最优方案。
|
||||
5. 由数据计算布局边界:适合世系树、可变列媒体和结构化关系画布。
|
||||
|
||||
## 已确认问题与选择
|
||||
|
||||
| 类别 | 已确认风险 | 五方案结论 | 最优方案 |
|
||||
| --- | --- | --- | --- |
|
||||
| 共享 `GenealogyCard` | 谱名省略、地点/人数/角色/更新时间不换行 | 1 不能覆盖未知长度;2 只能缓解窄屏;3 不应让普通卡片内部滚动;4 可保持完整数据并自然撑高;5 无结构坐标需求 | 4 |
|
||||
| T07 成员目录 | 成员卡固定 192/196rpx,姓名和元信息强制单行省略 | 1 仍有上限;2 仍依赖样本;3 破坏页面主滚动;4 与普通列表语义一致;5 无结构坐标需求 | 4 |
|
||||
| G06 搜索结果 | 结果卡虽有 `min-height`,仍用 `overflow: hidden` 裁切增长内容 | 1 无法解决裁切;2 无法覆盖长字段;3 普通结果卡不应独立滚动;4 删除裁切后由内容撑高;5 无结构坐标需求 | 4 |
|
||||
| G05 家谱概览 | 内容区使用百分比/固定 grid 行,长标题与详情可能互相挤压 | 1/2 只能推迟;3 不适合普通概览正文;4 可让各段按内容增长;5 当前无坐标关系 | 4 |
|
||||
| T01 世系树 | 固定 900rpx 画布、180×180 网格和固定世代轨道隐含容量上限 | 1/2 仍有容量上限;3 只能承担视口滚动;4 无法表达节点坐标与连线;5 可由世代数、单代节点数和间距推导画布 | 5,外层配合方案 3 |
|
||||
|
||||
## 保留边界
|
||||
|
||||
下列固定视觉边界不会因合同扫描被机械删除:图标尺寸、媒体缩略图比例与必要裁切、固定导航、真实弹窗/遮罩/Toast、装饰背景槽位。它们必须进入精确白名单并说明理由;普通正文、按钮文案、表单、列表卡和状态说明不得借白名单隐藏容量问题。
|
||||
|
||||
## 再审核要求
|
||||
|
||||
修复后必须重新运行全局容量合同、聚焦合同和压力数据 smoke,并在 320×568、360×640、360×800、412×915 下检查横向溢出、文本裁切、末项可达和滚动所有权。凡产生可见变化的既有 `[x]` 页面,只能作为新的视觉候选,必须由用户再次明确说“通过”后才能维持或新增 `[x]`。
|
||||
|
||||
## 实施与二次审核结果
|
||||
|
||||
- 容量合同从初始 279 项、规则纠正后的 276 项,收敛为 0 个未解释风险和 0 个失效白名单。最终 103 个白名单键均精确到文件、选择器和风险类型,只保留图标/印章/装饰分隔、固定导航、媒体缩略图裁切、G01/F01/T01 明确内部滚动壳及 T01 结构化节点边界。
|
||||
- 共享 `GenealogyCard`、T07、G06、F01、N01、M01、R01/R02、表单控件、状态卡与 G08—G12/T03/T08 的普通内容改为 `min-height`、换行和自然撑高;根容器不再用 `overflow-x: hidden` 掩盖横向问题。
|
||||
- G08/G11/G12 文本域使用 `auto-height`;G09 状态与操作分为独立数据行。二次截图发现的 G08/G11 原生 textarea 固有高度和 G09 状态操作重叠均已再次修正并复核。
|
||||
- T01 的画布宽高、行列数、世代轨、节点和关系线全部由成员数据计算。外层纵向滚动与内层横向滚动分工明确;120 节点(10 世代×每代 12 人)压力通过。
|
||||
- G01 弹层已验证 2/6/12/50 条:少量内容自然增长,达到安全上限后仅列表区滚动,50 条末项可达。
|
||||
- `data-driven-layout-runtime-smoke.js` 已验证:T07 50 条、G09 50 条、F09 30 项、3 倍长中文与 1.3 倍字号压力、四档视口、末项可达、横向溢出和表单自动增高。
|
||||
- 二次视觉对比覆盖 G05、T01、G08—G12、T03、T07、T08;对照长期 handoff 图检查了边框、装饰资产、间距和内容区。当前结果只能作为新的 H5 视觉候选,不能代替用户重新确认。
|
||||
|
||||
## 验证边界
|
||||
|
||||
- 相关静态合同(排除已知独立失败/缺失生成物项)全通过;21 个既有核心 runtime smoke、G01 50 条弹层压力和新增数据容量 smoke 通过。
|
||||
- `foundation-asset-audit` 与 `repository-handoff-size-contract` 的既有失败未通过删资料或放宽阈值处理;G06 runtime 截图一致性脚本因已授权清理 `docs/design/screens/runtime/` 不计入通过项。
|
||||
- 未运行会恢复长期目录外短期截图且含旧行为断言的 `t07-module-baseline-runtime-smoke.js`;T03—T08 核心流程由独立 smoke 通过。
|
||||
- 没有新的 Android/HBuilderX、系统字体缩放或真实接口证据;A06 仍为封存静态检查。
|
||||
|
||||
## 用户复核结论
|
||||
|
||||
- 用户已明确说“通过”,确认 G05、T01、G08—G12、T03、T07、T08 的本轮数据驱动布局迁移候选。
|
||||
- T01、T07 原有 `[x]` 可以维持;其他页面的本次确认只覆盖迁移后的 H5 可见效果,不替代真实入口、适用状态和整页流程验收。
|
||||
@@ -1,56 +0,0 @@
|
||||
# A01、G01 与 A02 视觉审视
|
||||
|
||||
> 日期:2026-07-13
|
||||
> 方法:通过用户授权的 Chrome 调试端口捕获真实 360 × 800 页面画面,并逐张检查。
|
||||
> 范围:只审视 A01、G01 两张已验收基准页与 A02 当前登录页;本记录不是用户最终验收。
|
||||
|
||||
## 1. 截图证据
|
||||
|
||||
| 页面 | 状态 | 截图 |
|
||||
| --- | --- | --- |
|
||||
| A01 启动/登录引导 | 用户已验收的认证基准 | `screens/runtime/2026-07-13/A01-360x800.png` |
|
||||
| G01 我的家谱 | 用户已验收的家谱基准 | `screens/runtime/2026-07-13/G01-360x800.png` |
|
||||
| A02 账号登录 | 当前实现,未验收 | `screens/runtime/2026-07-13/A02-before-360x800.png` |
|
||||
|
||||
## 2. A01、G01 共同的可继承规则
|
||||
|
||||
1. **完整资产承担外观。** 朱砂页头、纸纹、主/次按钮、题签框、谱印框、云纹和分区线都有明确的图片资产;文字与交互位于图片之上。
|
||||
2. **一屏只有一个视觉主角。** A01 是“家谱”标题与两枚操作按钮;G01 是当前家谱题签。其余山水、竹影和金线只托住主角。
|
||||
3. **信息层级有节奏。** 大号楷体标题、常规正文、弱化元信息分明;每一段间保留足够纸面,不把内容挤成普通管理表单。
|
||||
4. **朱砂只表达关键动作或当前状态。** A01 的登录、G01 的当前谱印与选中 Tab 使用朱砂;古金负责边框、图标和分隔。
|
||||
5. **同一类元素使用同一套成品。** A01 两枚按钮保持一致比例;G01 的当前题签、列表题签、谱印、快捷图标和底部导航形成连续的组件语言。
|
||||
6. **背景不抢任务。** 纸纹先提供可读底色,淡墨山水只落在空白和底部,不能穿过输入区或元信息。
|
||||
|
||||
## 3. A02 当前状态
|
||||
|
||||
### 可保留项
|
||||
|
||||
- 已有 A01 同源的纸纹、祠堂页头、谱印、淡墨背景和朱砂按钮皮肤。
|
||||
- 账号密码/手机验证码双 Tab、真实 input、忘记密码入口、协议判断与 A05 跳转仍应保留。
|
||||
- 360 × 800 下页面没有横向裁切,主按钮可见。
|
||||
|
||||
### 必须解决的可见问题
|
||||
|
||||
1. **登录面板是普通的细金线矩形。** 它没有完整纸面、双线、角纹或题签结构,和 A01 的完整按钮、G01 的完整谱签不属于同一视觉系统。
|
||||
2. **卡片内容过小且过密。** 标题、双 Tab、标签、占位文本、忘记密码与协议提示都落在一张矮卡内,用户进入后感受到的是“填写控件”,不是“进入家谱”。
|
||||
3. **按钮比例被压扁。** 当前主按钮使用约 4.49:1 的 A01 皮肤,却放入更扁的容器,边角和纹样失去端正的卷轴比例。
|
||||
4. **标题装饰没有成为层级。** 单侧云纹和细小返回符号像零散点缀,无法支撑“登录家谱”作为当前任务标题。
|
||||
5. **底部留白失衡。** 表单卡结束后到山水背景之间有大块无任务留白,而面板内部反而拥挤;需要把留白放在卡片外、把可读空间还给表单。
|
||||
|
||||
## 4. A02 设计决策
|
||||
|
||||
- 继续使用“家祠卷轴 · 庄重留白”,不另起现代渐变、玻璃卡片或圆角后台表单画风。
|
||||
- 认证头部与 A01 使用同一祠堂高度和谱印位置;头部以下先给清楚的“登录家谱”题签标题,再给完整不透明的认证面板资产。
|
||||
- 认证面板只承载 Tab、输入、忘记密码与协议说明;面板自身纸纹、金边和角纹由一张完整不透明 PNG 提供。
|
||||
- 主按钮只能按完整皮肤原比例显示;空间不足时缩小宽度或增加高度,不能纵向压缩图片。
|
||||
- 淡墨山水放到卡片结束后的留白与底部,作为收束,不穿过输入行。
|
||||
|
||||
## 5. 可见性与无障碍风险
|
||||
|
||||
- A02 的弱提示和协议文字在 360 × 800 下偏小,后续实现要保证正文至少 24rpx,主要点击区至少约 44dp。
|
||||
- 截图不能证明键盘焦点、读屏标签、错误提示朗读和真实 Android 字体回退;实施后仍需在 HBuilderX/Android 复核。
|
||||
- 本次只验证了正常态,没有验证输入错误、验证码倒计时或网络失败态;这些状态保持现有功能边界,不在本轮扩展接口。
|
||||
|
||||
## 6. 下一步
|
||||
|
||||
先为 A02 建立完整认证面板和正确比例按钮的资产合同,再做 A02 四尺寸前后对比;用户确认 A02 后,才将它作为 A03–A05 的认证样板。
|
||||
@@ -1,73 +0,0 @@
|
||||
# 页面与状态合并审计
|
||||
|
||||
> 审计日期:2026-07-14
|
||||
> 结论状态:G02→G01、G04→G03 已获用户授权并完成实施;其余四项仍是待实施建议。
|
||||
|
||||
## 1. 审计范围与证据
|
||||
|
||||
- 审计时 `pages.json` 实际登记 58 条路由;用户确认后已依序删除 G02、G04、G07 独立路由,当前由 `tests/full-page-visual-contract.ps1` 约束为 **55 条路由**。
|
||||
- G02 原先替换的是 `pages/genealogy/g02-empty-genealogies` 通用壳;该路由与 G01 的空态重复,现已收敛为 G01 的 `?state=empty`,不再进入独立页面验收。
|
||||
- 已在本轮重新捕获并审视 A01、A02、A04、A05、A06、G01 的 360×800 真实运行图:`screens/runtime/2026-07-14/route-audit/accepted-route-audit-360x800.png`。
|
||||
- G01 源码在 `hasGenealogies` 的 `v-else` 分支拥有“新建家谱/搜索并申请加入”两条动作;现已由 `forceEmptyState` 统一所有权。默认态前后对比为 `screens/runtime/2026-07-14/G01-default-before-after-360x800.png`,空状态为 `G01-empty-after-360x800.png` 与 `G01-empty-after-412x915.png`,均已获用户审美确认。
|
||||
- 其余 40 个仍渲染 `ModulePage` 的路由尚无独立视觉实现;其合并判断依据目前是规划中的任务语义、页面中文用途注释与目录状态,不将通用壳截图误当成最终交互。
|
||||
|
||||
## 2. 判断标准
|
||||
|
||||
保留独立路由,当用户进入的是不同的主任务、需要独立返回历史、可被深链/通知直接打开,或需要承载未保存的完整表单。
|
||||
|
||||
合并为同页状态,当差异仅是加载/空/失败/权限、查询前后、同一表单的步骤,或同一详情内的媒体预览与上传结果。状态由页面数据或查询参数驱动,不另建占位路由。
|
||||
|
||||
## 3. 已验收页面复核
|
||||
|
||||
| 已验收路由 | 结论 | 理由 |
|
||||
| --- | --- | --- |
|
||||
| A01 启动/登录引导 | 保留独立页 | 品牌入口、微信登录、注册入口与协议确认,和登录表单不是同一步。 |
|
||||
| A02 账号登录 | 保留独立页;现有双 Tab 正确 | 账号密码/短信验证码已经在同页状态切换,无需拆为 A03。 |
|
||||
| A04 注册账号 | 保留独立页 | 需要完整新用户表单、协议确认与返回链。 |
|
||||
| A05 重置密码 | 保留独立页 | 手机验证、验证码与两次密码确认是独立安全任务。 |
|
||||
| A06 登录状态 | 保留一个路由;现有六状态正确 | `normal/failed/restricted/register-pending/wechat-*` 已在同页状态配置中,未为每种错误新增页面。 |
|
||||
| G01 我的家谱 | 保留根页;合并 G02 | 已具备列表、加载和空态分支,G02 不应是第十二个家谱路由。 |
|
||||
|
||||
结论:已验收页面中没有需要为了减少页面而合并的认证页;A02 与 A06 已经是正确的“同页多状态”实现。唯一明确重复的是 G02→G01。
|
||||
|
||||
## 4. 全量路由建议
|
||||
|
||||
| 模块 | 现有路由/单元 | 保留为独立任务 | 合并为同页状态 | 路由数变化 |
|
||||
| --- | ---: | --- | --- | ---: |
|
||||
| 认证 | 5 | A01、A02、A04、A05、A06 | A02 Tab;A06 六状态均保留在各自页面内部 | 5 → 5 |
|
||||
| 家谱 | 9 | G01、G03、G05、G06、G08、G09、G10、G11、G12 | 已完成 G07→G06 搜索结果/无结果收敛 | 9 → 9 |
|
||||
| 世系树 | 8 | T01、T03、T04、T05、T06、T07 | T02→T01 的横屏/空/加载失败状态;T08→T03 的已故/隐私/无权状态 | 8 → 6 |
|
||||
| 家族内容 | 10 | F01、F02、F03、F04、F05、F06、F07、F08、F10 | F09→F08 的图片预览/上传/失败状态;F03 评论/删除确认和 F10 服务未开放也只作本页状态 | 10 → 9 |
|
||||
| 人物与礼仪 | 11 | R01--R11 | R02 编辑、R04 新增/详情/删除确认、R07 新建/编辑均已应作为各自路由内部状态 | 11 → 11 |
|
||||
| 消息 | 2 | N01、N02 | N01 空态、N02 已读均为内部状态 | 2 → 2 |
|
||||
| 个人中心 | 10 | M01--M10 | M01 登录态、M03 安全态、M09 空订单均为内部状态;M04/M05 保持独立,尤其 M05 符合用户已确认的“安全设置按钮跳转绑定手机号页面” | 10 → 10 |
|
||||
| **总计** | **55** | | **尚余 3 个重复路由合并** | **55 → 52** |
|
||||
|
||||
## 5. 建议的 52 条路由主表
|
||||
|
||||
### 合并清单(高置信)
|
||||
|
||||
1. 已删除 `G07` 路由;G06 同页管理初始搜索、结果列表与无结果,点选后进入 G08。G06 截图与自动验证已完成,等待用户审美确认。
|
||||
2. 删除 `T02` 路由;T01 同页管理树正常态、小屏横屏提示、无成员、加载与失败。
|
||||
3. 删除 `T08` 路由;T03 同页管理正常成员、已故、隐私隐藏、无权限。T07 仅保留目录和搜索结果。
|
||||
4. 删除 `F09` 路由;F08 同页管理照片墙、预览、选择上传、上传中、失败重试。
|
||||
|
||||
### 暂不合并的边界
|
||||
|
||||
- 不把 A01/A02、A04/A05、M04/M05 合成万能认证/安全页:这些是不同主任务,且 M05 的独立跳转是用户已确认的产品要求。
|
||||
- 不把 G08/G09 合并:一次申请表单与“我的全部申请记录”是不同任务和信息密度。
|
||||
- 不把 T04/T06、F02/F06、R03/R05/R08--R11 合并:对象、数据结构与返回历史不同;可复用表单组件,但不应强行共享一个路由。
|
||||
- F10 是否并入 F01 的媒体 Tab 不足以从当前规划判断,暂保留 F10 路由;待家族首页信息架构定稿后再评估,不计入 52 的高置信精简。
|
||||
|
||||
## 6. 已完成的 G02、G04、G07 收敛
|
||||
|
||||
G02 不进入独立视觉验收。用户已授权实施后,独立页面、G02 契约/冒烟测试、专属截图与“等待验收”文档已删除;最终 ImageGen 面板已改名为 `g01-empty-panel.png`,归属 G01 `state=empty`,现已获用户确认。G04 也已删除;其字段、校验与真实保存行为归属 G03 `step=ancestor`,并已获用户继续授权通过。G07 也已删除;公开检索的初始、结果和无结果均归属 G06,结果点选只进入 G08。`tests/g01-empty-state-contract.ps1`、`tests/g03-create-flow-contract.ps1`、`tests/g06-search-flow-contract.ps1` 与对应 Chrome 冒烟测试保护三条收敛链路;G01 默认态前后对比和 G06 前后对比证明已验收状态没有可见回退,G06 本身仍待用户审美确认。
|
||||
|
||||
## 7. 推荐执行顺序(G06 三态等待审美确认)
|
||||
|
||||
1. G02→G01、G04→G03、G07→G06 已完成,路由总数已从 58 变为 55;G01 两种状态与 G03 双步骤已获用户确认,G06 三态等待审美确认。
|
||||
2. 用户已授权连续推进;下一项实施 T02→T01,路由数变为 54。
|
||||
3. 在世系树完成 T01、T03 的独立设计时同步吸收 T02、T08,不先为状态壳单独生成资产。
|
||||
4. 在相册 F08 设计时同步吸收 F09;其余模块按“先任务页、后状态”推进。
|
||||
|
||||
除已获用户确认的 G02→G01 外,未经用户确认,本审计不改动其余路由、已验收页面或现有未提交内容。
|
||||
@@ -1,81 +0,0 @@
|
||||
# A-01 启动 / 登录引导设计记录
|
||||
|
||||
> 状态:A-01 文字注册入口方案、四尺寸静态验收、本地 UI 与 412 × 915 运行时视觉基准已完成并获用户验收;A-02 双标签本地 UI 已完成并获用户验收。A-03 已删除;协议页去向、A-04 至 A-06 的设计、认证接口与 Android 全尺寸回归验收仍未完成,D2 继续进行中。
|
||||
>
|
||||
> 本页设计事实的唯一归档位置为本文件。旧的 `2026-07-13-a01-login-guide-design.md` 与 `2026-07-13-a01-login-guide.md` 仅保留为失效历史,不可作为实现或推进下一页的依据。
|
||||
|
||||
## 1. 已归档的美术锚点
|
||||
|
||||
- v1–v3 早期整页效果稿已于 2026-07-17 上传前清理;历史入口结构保留在本记录与项目外完整归档中,不再作为当前方案依据。
|
||||
- 当前可恢复美术源:[`assets/a01-vnext/source/A01-layered-source-v1.psd`](assets/a01-vnext/source/A01-layered-source-v1.psd) 与 [`a01-psd-manifest.json`](assets/a01-vnext/source/a01-psd-manifest.json)。
|
||||
- 当前运行时视觉基准:[`screens/A01-启动登录引导-栅格验收-412x915.png`](screens/A01-启动登录引导-栅格验收-412x915.png),用于 A-01 协议未勾选状态的同尺寸像素比对;`pages/auth/entry.vue` 必须使用真实页面结构还原,不得将整张效果图作为页面内容。
|
||||
- 用途:保留本轮已确认的“家祠卷轴”欢迎页美术方向,不作为可直接还原的代码布局图,也不作为功能图标资产。
|
||||
- 画面方向:朱砂家祠红头、暖宣纸主体、古金细线与云纹、墨褐标题、两侧淡墨建筑/竹影与底部山水。登录欢迎页后续必须使用独立的红头/宣纸背景组合,不复用 G01 的列表页大山水底图。
|
||||
|
||||
## 2. 已确认的页面结构与交互
|
||||
|
||||
- 标题区保留“家谱 / 为家族留存可传承的记忆”,使用美术锚点中的朱砂家祠红头、暖宣纸、古金云纹、墨褐文字与淡墨建筑/竹影层次。
|
||||
- 操作区只保留两个大按钮和一个文字入口:朱砂主按钮“登录”、古金描边按钮“微信登录”,以及其下方的“还没有账号?注册账号”。“注册账号”仅为朱砂红可点击文字,不再作为独立大按钮。
|
||||
- 入口顺序:登录为主入口;微信登录为次入口;注册账号为辅助文字入口。
|
||||
- 已勾选协议后,“登录”前往 `A-02`,且 `A-02` 内固定使用“账号密码登录 / 手机验证码登录”双标签切换,默认显示账号密码登录。微信授权取消、失败与受限状态以后归 `A-06`;当前入口继续提示“微信登录功能准备中”,不新增真实授权跳转。
|
||||
- 注册账号入口仍归 `A-04`;忘记密码仍归 `A-05`。这两页完成设计前,当前入口只提示页面准备中,不伪造跳转。
|
||||
- 协议必须为用户主动勾选:初始未勾选,可分别打开《家谱用户协议》和《隐私政策》。不得使用“登录即表示同意”或任何默认同意文案;未勾选时登录操作应明确提示用户先确认协议。
|
||||
|
||||
## 3. 固定布局与状态边界
|
||||
|
||||
- 页面采用纵向滚动;`320 × 568` 下两个按钮、注册文字和协议行可以滚动,但任一按钮文字、注册文字、协议行和底部内容不得被系统导航区遮挡。
|
||||
- A-01 的登录与微信功能图标均需采用独立本地透明 PNG;微信标志只使用官方或许可来源的原色透明 PNG,不自行重绘或改色。账号密码与手机号验证码的表单图标归 A-02 设计时确定。
|
||||
- A-01 只设计欢迎、入口与协议未勾选提示。账号密码与手机号验证码表单均归 `A-02`,微信授权取消、失败与受限结果归 `A-06`,注册归 `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-06 |
|
||||
| 注册已确认协议 | 点击“注册账号”文字 | 提示“注册页面准备中” | 留在 A-01,等待 A-04 设计完成 |
|
||||
|
||||
### A-01 资产清单
|
||||
|
||||
| 资产 | 用途 | 来源或生成提示 | 尺寸与验收 |
|
||||
| --- | --- | --- | --- |
|
||||
| `docs/design/assets/a01-vnext/source/A01-layered-source-v1.psd` | A-01 可恢复视觉源 | 当前分层 PSD 与清单 | 页面以真实 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/foundation/opaque/a01-primary-button.png`、`a01-secondary-button.png` | A01 登录与微信登录的完整按钮皮肤 | 2026-07-13 以运行时视觉基准的比例与画风生成;不裁切设计图 | `1877 × 418px`、`1881 × 419px` 不透明 PNG;分别承担底色、双线边框和四角纹样,DOM 仅叠放图标与文案 |
|
||||
| `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-04 至 A-06 的设计仍未完成;A-03 已删除。这些后续范围不影响当前 A-01 视觉基准的验收结论。
|
||||
|
||||
## 5. 推进约束
|
||||
|
||||
- `A-01` 与 `A-02` 的本地 UI 已按本记录及实现合同落地并获用户视觉验收;A-03 已删除,后续仅在 A04--A06 获得明确授权后推进。
|
||||
- 当前 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,73 +0,0 @@
|
||||
# A02 账号登录设计记录
|
||||
|
||||
> 状态:已于 2026-07-14 获用户视觉验收,A02 可见效果冻结。
|
||||
> 路由:`pages/auth/a02-login`;A01 已确认协议进入时附带 `?agreed=1`。
|
||||
> 视觉来源:已验收的 A01 启动/登录引导与 G01 我的家谱。
|
||||
|
||||
## 1. 页面任务
|
||||
|
||||
让用户在“账号密码登录”与“手机验证码登录”之间切换并进入家谱。A02 自己提供协议确认:直接进入默认未同意,A01 已确认协议进入时默认已同意;没有账号的新用户可从协议行下方进入既有 A04 注册路由。页面只保留本地字段校验、A04 注册跳转、A05 忘记密码跳转与待接入 toast;不新增真实账号、短信、微信或后端逻辑。
|
||||
|
||||
手机号验证码登录只由 A02 的“手机验证码登录”Tab 承接;重复的 A03 页面已于 2026-07-14 删除。绑定手机号以后属于个人中心安全设置,微信授权取消、失败与受限状态以后属于 A06。
|
||||
|
||||
## 2. 视觉结构
|
||||
|
||||
1. 使用与 A01 同高度的朱砂祠堂头和居中谱印,保证认证入口属于同一品牌场景。
|
||||
2. 头部下方放置一张完整不透明的卷轴认证面板,而不是普通 CSS 矩形卡;标题、双 Tab、输入、辅助链接和按钮文字都是可编辑的 Vue 内容层。
|
||||
3. 面板上部承载任务标题和双 Tab,中部承载两行输入与辅助操作,下部留给原比例朱砂按钮、可点击协议确认行、轻量文字注册入口与淡墨山水收束。
|
||||
4. 主按钮继续复用 A01 的 `a01-primary-button.png`,显示槽高度为 128rpx;不把约 4.49:1 的皮肤压进旧的 100rpx 矮按钮。
|
||||
5. 背景山水只出现在认证面板之外和面板底部,保证输入、标签与占位文本的对比度。
|
||||
|
||||
## 3. 资产合同
|
||||
|
||||
| 路径 | 属性/原图尺寸 | 页面显示与职责 |
|
||||
| --- | --- | --- |
|
||||
| `static/assets/foundation/opaque/auth-header.png` | opaque / 750×196 | 认证祠堂头,A01 与 A02 共用。 |
|
||||
| `static/assets/foundation/transparent/brand-seal.png` | transparent / 240×288 | 头部谱印。 |
|
||||
| `static/assets/modules/auth/opaque/a02-login-panel.png` | opaque / 940×1672 | 完整卷轴登录面板,承载纸纹、双金线、角纹、云纹与底部山水;A02 容器 666rpx × 1184rpx。 |
|
||||
| `static/assets/foundation/opaque/a01-primary-button.png` | opaque / 1877×418 | A02 唯一主操作按钮皮肤,显示槽 128rpx 高。 |
|
||||
| `static/assets/foundation/transparent/chevron-right.png` | transparent / 96×96 | 返回图标,水平翻转后使用,不再用文字符号或 CSS 圆框。 |
|
||||
| `static/assets/modules/auth/transparent/a02-agreement-unchecked.png` | transparent / 96×96 | A02 本页未同意协议图标;四角透明,不以 CSS 圆环代替。 |
|
||||
| `static/assets/modules/auth/transparent/a02-agreement-checked.png` | transparent / 96×96 | A02 本页已同意协议图标;四角透明,不以文字勾号或 CSS 底色代替。 |
|
||||
|
||||
认证面板由内置 ImageGen 根据 A01、G01 的已验收截图生成;提示词约束为“完整不透明暖宣纸、双古金线、角纹、边缘淡云竹、中央留给真实控件、无文字无按钮无图标”。生成源保留在 Codex 生成目录,项目运行时只引用上表中的最终资产。
|
||||
|
||||
## 4. 交互与可读性
|
||||
|
||||
- 两个 Tab 仍由 `activeTab` 切换;已验证手机验证码 Tab 的真实页面状态。
|
||||
- 账号、密码、手机号、验证码继续使用原生 `input`,不烘焙到图片。
|
||||
- A02 不绘制面板或主按钮的 CSS `border`/`background`;对应完整外观由图片资产承担。
|
||||
- A02 的 `agreed` 是本页登录提交的唯一协议状态;H5 从当前 `location.hash` 读取 `agreed=1`,Android 从当前 uni 页面选项读取,直接进入默认未同意。
|
||||
- 协议行点击可切换两枚透明图标;未同意提交时在本页提示“请先阅读并同意相关协议”,不再要求返回 A01。协议名称仍沿用“协议页面准备中”提示,不新增协议正文路由。
|
||||
- 协议行下方显示“还没有账号? 注册账号”;只有朱砂色“注册账号”具有 88rpx 高的文字触点,点击进入既有 `/pages/auth/a04-register`。该入口不依赖协议状态,也不在本轮重做 A04。
|
||||
- 320 × 568 下可纵向滚动,主按钮位于首屏可达区域;360 × 640、360 × 800、412 × 915 均无横向裁切。
|
||||
|
||||
## 5. 运行时证据
|
||||
|
||||
| 状态 | 截图 |
|
||||
| --- | --- |
|
||||
| 改造前,账号密码 Tab,360×800 | `screens/runtime/2026-07-13/A02-before-360x800.png` |
|
||||
| 改造后,账号密码 Tab,320×568 | `screens/runtime/2026-07-13/A02-after-320x568.png` |
|
||||
| 改造后,账号密码 Tab,360×640 | `screens/runtime/2026-07-13/A02-after-360x640.png` |
|
||||
| 改造后,账号密码 Tab,360×800 | `screens/runtime/2026-07-13/A02-after-360x800.png` |
|
||||
| 改造后,账号密码 Tab,412×915 | `screens/runtime/2026-07-13/A02-after-412x915.png` |
|
||||
| 改造后,手机验证码 Tab,360×800 | `screens/runtime/2026-07-13/A02-sms-360x800.png` |
|
||||
| 本页协议未同意,320×568 | `screens/runtime/2026-07-14/A02-agreement-unchecked-320x568.png` |
|
||||
| 本页协议未同意,360×640 | `screens/runtime/2026-07-14/A02-agreement-unchecked-360x640.png` |
|
||||
| 本页协议未同意,360×800 | `screens/runtime/2026-07-14/A02-agreement-unchecked-360x800.png` |
|
||||
| 本页协议未同意,412×915 | `screens/runtime/2026-07-14/A02-agreement-unchecked-412x915.png` |
|
||||
| A01 已确认协议进入,360×800 | `screens/runtime/2026-07-14/A02-agreement-checked-360x800.png` |
|
||||
| 含注册入口、本页协议未同意,360×800 | `screens/runtime/2026-07-14/A02-register-entry-360x800.png` |
|
||||
| 含注册入口、本页协议未同意,412×915 | `screens/runtime/2026-07-14/A02-register-entry-412x915.png` |
|
||||
| A03 删除后,手机验证码 Tab,360×800 | `screens/runtime/2026-07-14/A02-sms-tab-after-a03-removal-360x800.png` |
|
||||
|
||||
## 6. 自动检查
|
||||
|
||||
- `tests/a02-asset-alpha-audit.ps1`:A02 卷轴面板稳定;两枚协议图标均为 96×96、带 Alpha、四角透明且包含不透明图形。
|
||||
- `tests/a01-a02-ui-contract.ps1`:A02 使用完整卷轴面板与完整主按钮;协议行、双状态图标、H5/Android 路由初始化和本页提交门禁可审计;保留 Tab、原生输入、A05 跳转;拒绝 CSS 面板、主按钮和协议图标外观。
|
||||
- `tests/a02-register-entry-contract.ps1`:A02 注册入口的文案、阅读顺序、88rpx 触点、A04 跳转与目标路由均可审计。
|
||||
- `tests/a03-route-removal-contract.ps1`:A03 页面、路由和临时目录条目均不存在;G02/G04 合并后,56 条最终路由与 A02 的唯一短信入口职责同步收敛。
|
||||
- `tests/compile-audit.ps1`:Vue 结构可编译。
|
||||
- `tests/capture-chrome-page-contract.ps1`:Chrome 截图在目标 URL、新文档、页面选择器和全部图片资源就绪后再采集,防止复用状态、黑块或半渲染证据。
|
||||
|
||||
用户已确认本页视觉结果。后续认证页可参考其已验收的品牌层级与完整位图面板规则,但不得直接复制其登录表单或改变 A02 的可见效果。
|
||||
@@ -1,60 +0,0 @@
|
||||
# A04 注册账号设计记录
|
||||
|
||||
> 状态:用户已于 2026-07-14 视觉验收通过;A04 可见效果冻结。
|
||||
> 路由:`pages/auth/a04-register`;A02 的注册入口与本页“登录”入口构成新用户与已有用户的双向路径。
|
||||
> 视觉来源:已验收的 A01 启动/登录引导、A02 账号登录与 G01 我的家谱。
|
||||
|
||||
## 1. 页面任务
|
||||
|
||||
让新用户填写手机号、设置密码、确认密码并在本页主动确认协议。A02 是唯一的账号密码/手机验证码登录页;A04 只处理注册,不重复验证码登录。真实注册、短信、微信、协议正文和后端接口均不在本页实现。
|
||||
|
||||
## 2. 视觉结构
|
||||
|
||||
1. 复用 A02 的朱砂祠堂头与居中谱印,让注册仍属于同一认证场景。
|
||||
2. 使用完整不透明 `a02-login-panel.png` 承担卷轴纸纹、双金线、角纹、云纹和底部山水;A04 不绘制 CSS 卡片、边框或按钮表面。
|
||||
3. 面板标题下使用既有结饰位图建立分隔,随后是三行真实输入、完整朱砂“注册账号”按钮、协议确认行和“已有账号?登录”回流入口;不保留挤压表单的独立引导文案。
|
||||
4. 360 × 800 与 412 × 915 均完整呈现;较小屏幕保留纵向滚动,不允许横向裁切。
|
||||
|
||||
## 3. 资产合同
|
||||
|
||||
| 路径 | 属性/原图尺寸 | A04 职责 |
|
||||
| --- | --- | --- |
|
||||
| `static/assets/foundation/opaque/auth-page-paper.jpg` | opaque / 750×1334 | 认证页纸纹底色。 |
|
||||
| `static/assets/foundation/opaque/auth-header.png` | opaque / 750×196 | 朱砂祠堂头。 |
|
||||
| `static/assets/foundation/transparent/brand-seal.png` | transparent / 240×288 | 头部谱印。 |
|
||||
| `static/assets/foundation/transparent/auth-divider-knot.png` | transparent / 160×96 | 标题与表单之间的位图结饰分隔。 |
|
||||
| `static/assets/modules/auth/opaque/a02-login-panel.png` | opaque / 940×1672 | A04 完整卷轴注册面板。 |
|
||||
| `static/assets/foundation/opaque/a01-primary-button.png` | opaque / 1877×418 | A04 注册主按钮皮肤。 |
|
||||
| `static/assets/modules/auth/transparent/a02-agreement-unchecked.png` | transparent / 96×96 | 本页未同意协议图标。 |
|
||||
| `static/assets/modules/auth/transparent/a02-agreement-checked.png` | transparent / 96×96 | 本页已同意协议图标。 |
|
||||
|
||||
现有认证资产已完整覆盖本页,未使用 ImageGen,也未新增视觉资产。
|
||||
|
||||
## 4. 交互合同
|
||||
|
||||
- `phone`、`password`、`confirmPassword` 与 `agreed` 都是 A04 本页状态;协议初始未同意。
|
||||
- 点击协议行切换两枚透明图标;点击协议名称仅提示“协议页面准备中”。
|
||||
- 注册前依次校验协议、11 位手机号、密码非空和两次密码一致;通过后仅提示“注册服务待接入”。
|
||||
- “已有账号?登录”真实跳转 `/pages/auth/a02-login`;Chrome 已验证该 URL。
|
||||
- A04 已从 `data/page-catalog.js` 移出,不再使用 `ModulePage` 临时母版。
|
||||
|
||||
## 5. 运行时证据
|
||||
|
||||
| 状态 | 截图 |
|
||||
| --- | --- |
|
||||
| 改造前,通用表单壳,360×800 | `screens/runtime/2026-07-14/A04-before-360x800.png` |
|
||||
| 改造前,通用表单壳,412×915 | `screens/runtime/2026-07-14/A04-before-412x915.png` |
|
||||
| 改造后,注册初始态,360×800 | `screens/runtime/2026-07-14/A04-after-360x800.png` |
|
||||
| 改造后,注册初始态,412×915 | `screens/runtime/2026-07-14/A04-after-412x915.png` |
|
||||
| 平衡修订审视前,360×800 | `screens/runtime/2026-07-14/A04-audit-before-360x800.png` |
|
||||
| 平衡修订审视前,412×915 | `screens/runtime/2026-07-14/A04-audit-before-412x915.png` |
|
||||
| 平衡修订后,注册初始态,360×800 | `screens/runtime/2026-07-14/A04-after-balance-360x800.png` |
|
||||
| 平衡修订后,注册初始态,412×915 | `screens/runtime/2026-07-14/A04-after-balance-412x915.png` |
|
||||
|
||||
## 6. 自动检查
|
||||
|
||||
- `tests/a04-registration-contract.ps1`:独立页面、完整位图面板/按钮、结饰分隔、长标签留白、三项字段、协议状态、校验、A02 回流和禁止 CSS 伪造均可审计。
|
||||
- `tests/compile-audit.ps1`:A04 不使用失效目录条目,Vue 文件与现有本地导入可编译。
|
||||
- `tests/full-page-visual-contract.ps1`:A04 保持最终编号路由;G02/G04 合并后全项目共 56 条路由。
|
||||
|
||||
用户已于 2026-07-14 确认本页通过视觉验收;后续可审视 A05,但不得改变 A04 可见效果。
|
||||
@@ -1,57 +0,0 @@
|
||||
# A05 重置密码设计记录
|
||||
|
||||
> 状态:用户已于 2026-07-14 视觉验收通过;A05 可见效果冻结。
|
||||
> 路由:`pages/auth/a05-reset-password`;A02 的“忘记密码”入口进入本页,本页“返回登录”回到 A02。
|
||||
|
||||
## 1. 页面任务与边界
|
||||
|
||||
让用户通过已绑定手机号和验证码重设登录密码。A05 使用手机号、验证码、新密码、确认新密码四项本页状态,避免在没有两次确认的情况下覆盖密码。
|
||||
|
||||
真实短信、验证码下发、账号校验、密码重设接口和后端数据均不在本页实现。“获取验证码”仅提示“验证码功能待接入”;所有字段有效后仅提示“密码重设服务待接入”。
|
||||
|
||||
## 2. 视觉结构
|
||||
|
||||
1. 复用 A02/A04 已验收的朱砂祠堂头、谱印、宣纸、淡墨山水和完整卷轴认证面板,使重置密码仍属于同一认证场景。
|
||||
2. 标题为“重设密码”,副标题说明“验证手机号后重新设置登录密码”,不暴露 A-05 内部编号。
|
||||
3. 标题和表单之间使用既有结饰位图;正文依次为手机号、验证码(含文字型获取动作)、新密码、确认新密码、完整朱砂主按钮、完成提示与返回登录入口。
|
||||
4. 面板、按钮、页头和结饰均由既有位图提供;CSS 只处理布局、文字、输入与交互状态,不绘制 CSS 卡片或按钮表面。
|
||||
|
||||
## 3. 资产合同
|
||||
|
||||
| 路径 | 属性/原图尺寸 | A05 职责 |
|
||||
| --- | --- | --- |
|
||||
| `static/assets/foundation/opaque/auth-page-paper.jpg` | opaque / 750×1334 | 认证页纸纹底色。 |
|
||||
| `static/assets/foundation/transparent/auth-ink-scenery.png` | transparent / 750×1334 | 认证页淡墨叠层。 |
|
||||
| `static/assets/foundation/opaque/auth-header.png` | opaque / 750×196 | 朱砂祠堂头。 |
|
||||
| `static/assets/foundation/transparent/brand-seal.png` | transparent / 240×288 | 头部谱印。 |
|
||||
| `static/assets/modules/auth/opaque/a02-login-panel.png` | opaque / 940×1672 | A05 完整卷轴重设面板。 |
|
||||
| `static/assets/foundation/opaque/a01-primary-button.png` | opaque / 1877×418 | “确认重设”完整主按钮皮肤。 |
|
||||
| `static/assets/foundation/transparent/auth-title-cloud.png` | transparent / 200×120 | 标题右侧祥云。 |
|
||||
| `static/assets/foundation/transparent/auth-divider-knot.png` | transparent / 160×96 | 标题与表单之间的结饰。 |
|
||||
| `static/assets/foundation/transparent/chevron-right.png` | transparent / 96×96 | 返回动作图标。 |
|
||||
|
||||
现有认证资产已完整覆盖本页,未使用 ImageGen,也未新增视觉资产。
|
||||
|
||||
## 4. 交互合同
|
||||
|
||||
- 空提交先提示“请输入正确手机号”;手机号必须为 11 位,验证码必须为 6 位,密码非空且两次一致。
|
||||
- 点击“获取验证码”显示“验证码功能待接入”。
|
||||
- 点击“返回登录”真实跳转 `/pages/auth/a02-login`;Chrome 已验证 URL。
|
||||
- A05 已从 `data/page-catalog.js` 移出,不再使用 `ModulePage` 临时母版。
|
||||
|
||||
## 5. 运行时证据
|
||||
|
||||
| 状态 | 截图 |
|
||||
| --- | --- |
|
||||
| 改造前,通用白卡壳,360×800 | `screens/runtime/2026-07-14/A05-before-360x800.png` |
|
||||
| 改造前,通用白卡壳,412×915 | `screens/runtime/2026-07-14/A05-before-412x915.png` |
|
||||
| 改造后,重设密码初始态,360×800 | `screens/runtime/2026-07-14/A05-after-360x800.png` |
|
||||
| 改造后,重设密码初始态,412×915 | `screens/runtime/2026-07-14/A05-after-412x915.png` |
|
||||
|
||||
## 6. 自动检查
|
||||
|
||||
- `tests/a05-reset-password-contract.ps1`:独立页面、完整位图面板/按钮、四项字段、验证码动作、校验、A02 回流、目录项移除和禁止 CSS 伪造可审计。
|
||||
- `tests/compile-audit.ps1`:A05 不使用失效目录条目,Vue 文件与现有本地导入可编译。
|
||||
- `tests/full-page-visual-contract.ps1`:A05 保持最终编号路由;G02/G04 合并后全项目共 56 条路由。
|
||||
|
||||
用户已于 2026-07-14 确认本页通过视觉验收;后续可审视 A06,但不得改变 A05 可见效果。
|
||||
@@ -1,50 +0,0 @@
|
||||
# A06 登录状态设计记录
|
||||
|
||||
> 状态:用户已于 2026-07-14 视觉验收通过;A06 可见效果冻结。
|
||||
> 唯一路由:`pages/auth/a06-auth-status`;以 `?status=` 表示当前认证状态,不新建状态页面。
|
||||
|
||||
## 1. 页面任务与边界
|
||||
|
||||
A06 承接认证流程中不能继续进入家谱的结果:登录未完成、登录失败、账号暂时受限、注册尚未完成、微信授权取消和微信授权失败。
|
||||
|
||||
没有接入真实账号状态、客服、申诉、微信授权或后端接口。账号受限的“查看帮助”只显示“账号申诉功能待接入”;注册未完成真实跳转 A04;其余主操作与次操作真实跳转 A02。绑定手机号仍属于后续个人中心安全设置页面,本页不承担该功能。
|
||||
|
||||
## 2. 状态合同
|
||||
|
||||
| `status` 值 | 标题 | 主操作 | 次操作 |
|
||||
| --- | --- | --- | --- |
|
||||
| `normal`(含未知值回退) | 登录未完成 | 返回登录 → A02 | 无 |
|
||||
| `failed` | 登录失败 | 返回登录 → A02 | 无 |
|
||||
| `restricted` | 账号暂时受限 | 查看帮助 → 本地提示 | 返回登录 → A02 |
|
||||
| `register-pending` | 注册尚未完成 | 继续注册 → A04 | 返回登录 → A02 |
|
||||
| `wechat-cancelled` | 微信授权已取消 | 返回登录 → A02 | 无 |
|
||||
| `wechat-failed` | 微信授权失败 | 返回登录 → A02 | 无 |
|
||||
|
||||
H5 由 `location.hash` 的查询参数读取状态;uni-app 原生端读取当前页 `options`。两端对未知值统一回退 `normal`,避免状态壳无文案或失去下一步。
|
||||
|
||||
## 3. 视觉结构与资产
|
||||
|
||||
1. 删除通用白卡、CSS 红方徽章和压缩按钮,改为 A02/A04/A05 已验证的“朱砂祠堂头 → 卷轴面板 → 状态说明 → 朱砂操作”的认证节奏。
|
||||
2. 状态图形只使用已有透明 `auth-login-outline.png`,避免为每种异常绘制夸张警示符号;文案承担状态区别,朱砂只强调标签与主操作。
|
||||
3. 主操作保持完整 `a01-primary-button.png` 位图比例;受限与注册未完成的次操作为低权重朱砂文字,不与主按钮竞争。
|
||||
4. 页面壳、面板、按钮、边框、云纹和结饰均由既有位图提供;CSS 只处理布局、文字与点击态。现有资产已完整覆盖需求,因此未调用 ImageGen、未新增资产。
|
||||
|
||||
## 4. 运行时审视与证据
|
||||
|
||||
| 状态 | 截图 |
|
||||
| --- | --- |
|
||||
| 改造前通用壳,360×800 | `screens/runtime/2026-07-14/A06-before-360x800.png` |
|
||||
| 改造前通用壳,412×915 | `screens/runtime/2026-07-14/A06-before-412x915.png` |
|
||||
| `normal` 默认态,360×800 | `screens/runtime/2026-07-14/A06-after-360x800.png` |
|
||||
| `normal` 默认态,412×915 | `screens/runtime/2026-07-14/A06-after-412x915.png` |
|
||||
| `restricted` 受限态,360×800 | `screens/runtime/2026-07-14/A06-restricted-360x800.png` |
|
||||
|
||||
截图审视结论:两个尺寸中,朱砂头图、卷轴框、状态图形、标题/说明、完整主按钮与底部淡墨山水均未被裁切;360×800 的面板底部在首屏内结束,412×915 保留呼吸留白;`restricted` 的次操作保持在主按钮下方且不抢主路径。当前未发现黑块、图片未加载、横向溢出或 CSS 伪造完整表面。
|
||||
|
||||
## 5. 自动与交互检查
|
||||
|
||||
- `tests/a06-auth-status-contract.ps1`:约束唯一路由、独立页面、六个状态键、状态动作、A02/A04 目标、既有位图资产与禁止 CSS 伪造。
|
||||
- `tests/a06-auth-status-runtime-smoke.js`:通过 Chrome DevTools 9222 真实验证默认主操作进入 A02、受限主操作展示本地帮助提示、注册未完成主操作进入 A04。脚本在每次 hash 跳转后刷新文档,以规避 H5 Vue 实例保留上一次状态。
|
||||
- `tests/compile-audit.ps1`:确认 A06 不再依赖已移除的目录临时项。
|
||||
|
||||
用户已于 2026-07-14 确认本页通过视觉验收;后续可开始 G02,但不得改变 A01、A02、A04、A05、A06、G01 的可见效果。若用户后续指定调整 A06,仍须重新截图、运行上述检查和相关回归。
|
||||
@@ -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,60 +0,0 @@
|
||||
# F/R/N/M 全量候选设计记录
|
||||
|
||||
> 日期:2026-07-14
|
||||
> 状态:F01–F10、R01–R11、N01–N02、M01–M10 共 33 个页面候选已形成,统一标记 `[~]`,等待用户逐页视觉审核。
|
||||
|
||||
## 1. Product Design 处理方式
|
||||
|
||||
- 先捕获旧通用壳的 `detail|list|form|timeline|settings|status` 六类真实运行基线,再升级共享母版。
|
||||
- 共享母版只统一视觉表面和状态机制;每条路由仍拥有独立页面编号、标题、字段、列表内容和业务任务,没有合并不同功能页面。
|
||||
- F01、F02、N01、M01 四个根页独立重做,保留家族/我的底部导航与关键跳转。
|
||||
- 接口文档只用于功能位置和字段语义;页面候选全部使用本地模拟数据,不调用 `appApi`,不修改 API 边界。
|
||||
|
||||
## 2. 位图与母版
|
||||
|
||||
- `g03-create-flow-panel.png`:表单、详情、设置、服务说明和统一状态的完整宣纸表面。
|
||||
- `g06-search-input-wide.png`:真实输入、详情段落和设置行的完整位图底层。
|
||||
- `application-status-card.png`:列表、时间轴、通知、动态与状态卡。
|
||||
- `a01-primary-button.png`/`a01-secondary-button.png`:所有主次操作。
|
||||
- CSS 不绘制卡片、按钮、边框、圆角或伪元素装饰,只负责布局、文字、控件和状态。
|
||||
|
||||
## 3. 截图证据
|
||||
|
||||
### 六类母版
|
||||
|
||||
目录:`screens/runtime/2026-07-14/module-page-audit/`
|
||||
|
||||
- 基线与候选:`00-*-before-360x800.png`、`01-f03-detail-360x800.png` 至 `10-f04-list-412x915.png`。
|
||||
- 同画布对比:`11-template-before-after-comparison.png`。
|
||||
- 共享空/成功/失败:`12-shared-states-comparison.png`。
|
||||
|
||||
### 29 个共享母版页面
|
||||
|
||||
目录:`screens/runtime/2026-07-14/all-page-candidates/`
|
||||
|
||||
- 每条路由均有独立 `*-360x800.png`。
|
||||
- 模块联系表:`00-family-contact-sheet.png`、`00-records-contact-sheet.png`、`00-profile-notification-contact-sheet.png`。
|
||||
|
||||
### F01/F02/N01/M01
|
||||
|
||||
目录:`screens/runtime/2026-07-14/root-pages-audit/`
|
||||
|
||||
- 基线:`00-f01-before-360x800.png`、`00-f02-before-360x800.png`、`00-n01-before-360x800.png`、`00-m01-before-360x800.png`。
|
||||
- 候选与状态:`01-f01-list-360x800.png` 至 `10-m01-ready-412x915.png`。
|
||||
- 同画布对比:`11-root-before-after-states.png`。
|
||||
|
||||
## 4. 审视结果
|
||||
|
||||
- 六类母版在 360 下没有卡片、按钮或文字裁切;列表较长时自然纵向滚动,完整面板保持一致的视觉终点。
|
||||
- 共享状态统一使用同一宣纸面板,但标题、动作和业务名称来自当前页面,避免“万能空态”丢失上下文。
|
||||
- F01、M01 的根页保留底部导航;F02 的发布编辑区和 N01 的消息卡均使用真实控件与本地可操作结果。
|
||||
- Product Design 对比中未发现 P1/P2 级裁切、假按钮槽或风格漂移;最终 Android 真机字体放大、读屏和触摸目标仍需后续设备复核。
|
||||
|
||||
## 5. 验证入口
|
||||
|
||||
- `tests/module-page-visual-contract.ps1`
|
||||
- `tests/module-page-runtime-smoke.js`
|
||||
- `tests/root-pages-visual-contract.ps1`
|
||||
- `tests/root-pages-runtime-smoke.js`
|
||||
- `tests/capture-chrome-page-contract.ps1`
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
# G01 列表背景候选与换机重建
|
||||
|
||||
> 日期:2026-07-16
|
||||
> 状态:A/B/C 历史母版与旗舰长屏母版均已保存并通过本机确定性构建;用户已确认旗舰长屏方向作为 9 个活动 G 页面的公共背景,并确认共享图片画层使用 28% 不透明度。当前仅有 H5 运行证据,不得写成任一页面整页已验收或 Android 已通过。
|
||||
|
||||
## 1. 为什么重做
|
||||
|
||||
现有 `footer-mountain-bamboo.png` 为 `750×360`,视觉主体集中在底部,上半部留白较大;页面又把它定位到整个内容末尾并使用 `scaleToFill` 拉伸,因此列表变长时山水被推到“添加家谱”下方。用户提出背景应更早出现,并进一步提出 G01 可能只滚动下方列表、上方当前家谱与快捷入口固定。
|
||||
|
||||
连续长背景只替换各 G 页面最底层视觉。G01 固定区/独立滚动边界已在后续同日按用户确认方案单独实施,不影响其他 G 页面;具体合同见 `docs/superpowers/specs/2026-07-16-g01-fixed-header-independent-list-scroll-design.md`。
|
||||
|
||||
## 2. 稳定输入
|
||||
|
||||
稳定输入位于:
|
||||
|
||||
- `docs/design/assets/g01-background/masters/g01-list-background-direction-a-chroma-master.png`
|
||||
- `docs/design/assets/g01-background/masters/g01-list-background-direction-b-chroma-master.png`
|
||||
- `docs/design/assets/g01-background/masters/g01-list-background-direction-c-paper-master.png`
|
||||
- `docs/design/assets/g01-background/masters/genealogy-page-background-long-flagship-imagegen-source.png`(原始 ImageGen 输出,793×1983)
|
||||
- `docs/design/assets/g01-background/masters/genealogy-page-background-long-flagship-master.png`(确定性归一化母版,1536×3840)
|
||||
|
||||
准确提示词、标题、处理模式、源尺寸、目标尺寸、选择状态和运行输出路径由 `design-pipeline/manifests/g01-background-candidates.json` 单一维护。A/B 使用纯 `#00ff00` 绿幕并提取透明叠加层;C 与旗舰长屏图保留完整暖宣纸底。当前公共运行输出是 `static/assets/modules/genealogy/opaque/genealogy-page-background-long.png`,由 `components/GenealogyPageBackground.vue` 唯一持有路径、贴底规则和 `opacity: 0.28` 显示规则;各 G 页面不得重复覆盖。旧 `genealogy-page-background.png` 不覆盖、不删除,仅作为上一轮 C 输出保留。
|
||||
|
||||
## 3. 跨电脑重建
|
||||
|
||||
要求:Windows、Node.js 22+、npm、Python 3.12+。本机验证环境为 Node `24.15.0`、npm `11.12.1`、Python `3.14.6`、Pillow `12.3.0`。
|
||||
|
||||
在项目根目录执行:
|
||||
|
||||
```powershell
|
||||
python --version
|
||||
node --version
|
||||
npm.cmd --version
|
||||
python -m venv design-pipeline/.venv
|
||||
design-pipeline/.venv/Scripts/python.exe -m pip install --upgrade pip
|
||||
design-pipeline/.venv/Scripts/python.exe -m pip install -r design-pipeline/requirements.txt
|
||||
npm.cmd --prefix design-pipeline run build:g01-background-candidates
|
||||
design-pipeline/.venv/Scripts/python.exe -m unittest design-pipeline/tests/test_build_g01_backgrounds.py -v
|
||||
node --test design-pipeline/tests/g01-background-candidates.test.mjs
|
||||
```
|
||||
|
||||
构建输出进入已忽略目录 `design-pipeline/generated/g01-background/`:
|
||||
|
||||
- `g01-list-background-direction-a.png`
|
||||
- `g01-list-background-direction-b.png`
|
||||
- `g01-list-background-direction-c.png`
|
||||
- `genealogy-page-background-long-flagship.png`
|
||||
- `build-report.json`
|
||||
|
||||
Node 入口只使用内置模块做路径与清单编排;图片解码、尺寸检查和像素处理由锁定版本的 Pillow 统一负责,因此 G01 候选构建不依赖已删除的 `node_modules`。项目其他使用 Sharp 的旧流水线仍需执行 `npm.cmd ci --prefix design-pipeline`。
|
||||
|
||||
## 4. 当前质量基线
|
||||
|
||||
本机输出:
|
||||
|
||||
| 候选 | 模式 | 像素 | 字节 | SHA-256 |
|
||||
| --- | --- | ---: | ---: | --- |
|
||||
| A | chroma-key | 1024×1536 | 2,280,297 | `7b8a8abecc651b1bc4c589607780c8ff00ea0f13de018ca4b27a479b2a870fe4` |
|
||||
| B | chroma-key | 1024×1536 | 1,890,556 | `3a0420eaaac9a2821da0f9a2ddbbbd6fb29b463fa6f41be7fa5c23948f782e2a` |
|
||||
| C | opaque-paper | 1024×1536 | 2,407,477 | `f14aeed81046d2d5e7ae78b1e62f572689c38261a2cc043142bef9f70200dc5b` |
|
||||
| 旗舰长屏 | opaque-paper-resize | 1440×3600 | 6,538,134 | `9aa9ebdd231eddbc151e238ba340a00f8603f23143117ad8c93009947dcca2e2` |
|
||||
|
||||
四张输出均通过清单尺寸、处理模式和 sRGB 构建检查;A/B/C 继续保持原质量基线。旗舰长屏原始 ImageGen 输出为 2,333,286 字节、SHA-256 `3d5ada7a23edd8db98b3511d308d2d240d80fdd4ff9280a8c434b50fb68cac3a`;1536×3840 归一化母版为 6,822,092 字节、SHA-256 `2fb3aa9be7772beb6f620c49f096ed6d9c045a56cfe7ae342664ee05ca58862b`。若另一台电脑因 Python、Pillow 或压缩实现差异产生不同文件哈希,应先核对锁定依赖,再比较解码后的 RGBA 像素和质量报告,不能只凭压缩字节差异判定视觉失败。
|
||||
|
||||
## 5. 可复现边界
|
||||
|
||||
- 精确可复现:从仓库内保存的母版执行清单、Python 和 Node 流程,得到相同 RGBA 像素处理结果。
|
||||
- 方向可复现但非逐像素:在另一台电脑上使用清单中的提示词重新调用图像模型。
|
||||
- 不可依赖:Codex 本机生成目录、`tmp/`、`node_modules/`、`.venv/`、`generated/` 或未进入仓库的聊天图片。
|
||||
|
||||
## 6. 下一步
|
||||
|
||||
1. 用户查看 G01 固定信息区/独立列表滚动的实际 H5 页面;当前五档 runtime 截图仍是内部候选证据。
|
||||
2. 用户确认 G01 整页后,再保存一张长期代表证据并更新视觉证据索引。
|
||||
3. 完成 Android/HBuilderX 真机或模拟器复核,并专项检查区域滚动手感及 4GB Android 上约 19.8MiB 解码内存、切页与回收。
|
||||
@@ -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,79 +0,0 @@
|
||||
# G05 家谱总览设计记录
|
||||
|
||||
> 日期:2026-07-14
|
||||
> 状态:视觉候选已实现并完成本轮 Product Design 截图审计;未获用户审美确认,等待 G 模块完成后集中查看。
|
||||
|
||||
## 1. 技能与证据
|
||||
|
||||
- 已调用 `Product Design:index`,将任务路由为现有产品的 `audit`、`get-context` 与实现后设计检查。
|
||||
- 已调用 `Product Design:audit`,以本轮新截的真实 H5 画面为证据,不用旧截图代替。
|
||||
- 已调用 ImageGen 生成完整不透明位图表面;没有用 CSS 伪造卡片、边框或装饰。
|
||||
- 改前证据:`screens/runtime/2026-07-14/g-module-audit/before/01-g05-before-360x800.png`。
|
||||
- 改后证据:`screens/runtime/2026-07-14/g-module-audit/g05/01-g05-ready-360x800.png`、`02-g05-empty-360x800.png`、`03-g05-error-360x800.png`、`04-g05-ready-412x915.png`。
|
||||
- 同画布对照:`screens/runtime/2026-07-14/g-module-audit/g05/05-g05-before-after-comparison.png`,左侧改前、右侧改后;所有判断均基于这张合并输入,不把两个分离预览冒充并排比较。
|
||||
|
||||
## 2. 产品与思维导图参考
|
||||
|
||||
外部只读参考位于 `C:\Users\Rain\Desktop\软件\JOB\app设计`。已查看 `思维导图.png`、`家谱主页.png`、`加入家谱.png` 与 `字辈谱.png`。
|
||||
|
||||
采用的只有信息结构:谱名、堂号、地点和成员概况置顶;世系、录入、字辈、审核作为核心入口;家族内容作为下一层。没有采用外部灰绿色卡片、现代图标、邀请码加入或底部双按钮视觉。
|
||||
|
||||
外部“邀请码加入”与当前产品不一致。当前唯一加入流程是 G06 公开检索 → G08 提交申请 → G09 查看进度 → G10 管理员审核,因此不把邀请码或邀请按钮加入 G05。
|
||||
|
||||
## 3. 路由与状态所有权
|
||||
|
||||
- 唯一路由:`pages/genealogy/g05-genealogy-overview`。
|
||||
- 唯一上下文查询键:`genealogyId`;G01 的旧 `id` 链接已同步迁移,不保留兼容读取。
|
||||
- 同页状态:`loading`、`ready`、`empty`、`error`,不为加载、空数据、无权或失败另建页面。
|
||||
- “录入族人”进入 G03 `step=ancestor&genealogyId=...`;已删除的 G04 不再被引用。
|
||||
- G05 不再把 G08 申请加入表单误写成管理员的“邀请亲人”。
|
||||
|
||||
## 4. OpenAPI 设计边界
|
||||
|
||||
| 页面内容/动作 | OpenAPI 依据 | 页面阶段处理 |
|
||||
| --- | --- | --- |
|
||||
| 谱名、堂号、地区、成员数、可见范围 | `GET /genealogy/app/genealogies/{genealogyId}/overview` | 正常态展示真实接口所需字段;本轮继续使用现有 mock |
|
||||
| 管理设置 | `GET/PUT /genealogy/app/genealogies/{genealogyId}` | 只进入 G11,不在 G05 复制设置表单 |
|
||||
| 世系树 | `GET /genealogy/app/genealogies/{genealogyId}/lineage/tree` | 进入 T01,不在总览加载完整树 |
|
||||
| 录入首代 | `POST /genealogy/app/genealogies/{genealogyId}/lineage/persons` | 进入 G03 的始祖步骤 |
|
||||
| 字辈谱 | `GET/POST /genealogy/app/genealogies/{genealogyId}/generation-poems` | 进入 G12 |
|
||||
| 入谱审核 | `GET .../join-applies/pending`、`PUT .../{applyId}/audit` | 进入 G10 |
|
||||
|
||||
接口响应仍使用通用 `JsonObject/ListResult`,因此页面不臆造邀请码、分享码、视频或多级管理员字段。
|
||||
|
||||
## 5. 位图资产
|
||||
|
||||
- 最终资产:`static/assets/modules/genealogy/opaque/g05-overview-surface.png`。
|
||||
- 尺寸:1122×1506;`Format24bppRgb`,四角 alpha 均为 255。
|
||||
- 内容:墨蓝谱名区、四个完整宣纸入口槽、古金分隔、底部访问说明面;文字和点击层不烘焙进图片。
|
||||
- ImageGen 使用 `ui-mockup` 模式,参考 G05 改前 360×800 截图,明确要求无文字、无图标、无占位符、无现代玻璃拟态。
|
||||
|
||||
## 6. Product Design 审计
|
||||
|
||||
### 改前主要风险
|
||||
|
||||
1. G05 的“录入族人”指向已删除 G04,核心动作实际不可达。
|
||||
2. “邀请亲人”跳到申请者填写的 G08,角色和任务语义错误;OpenAPI 也没有邀请端点。
|
||||
3. 深色头、四宫格卡片和底部邀请面均由 CSS 拼接,不符合完整视觉表面必须使用位图的项目规则。
|
||||
4. 只有模糊载入文案,没有区分空数据、无权限和加载失败。
|
||||
|
||||
### 改后步骤与健康度
|
||||
|
||||
1. `ready`:健康。谱名和成员概况在墨蓝区,四个核心任务在首屏可见,家族近况和访问说明层级清楚。
|
||||
2. `empty`:健康。仍在 G05 路由内说明缺少上下文,并返回“我的家谱”。
|
||||
3. `error`:健康。说明网络、数据或权限三类可能原因,提供同页重试。
|
||||
4. 412×915:健康。位图、文字和点击槽同步放大,没有横向溢出或裁切。
|
||||
|
||||
### 可访问性边界
|
||||
|
||||
- 本轮从截图确认了文字未裁切、主要入口具有完整卡片面积、按钮文案可见。
|
||||
- 审计后已提高谱名区元信息、入口说明、近况和底部说明字号,降低 360 宽屏小字风险。
|
||||
- 截图不能证明读屏语义、键盘焦点和 Android 字体放大行为;这些留到真机与可访问性专项验证,不能宣称完整 WCAG 合规。
|
||||
|
||||
## 7. 验证
|
||||
|
||||
- `tests/g05-overview-contract.ps1`
|
||||
- `tests/g05-overview-runtime-smoke.js`
|
||||
- `tests/g01-visual-contract.ps1`
|
||||
|
||||
以上在实现后均已通过;最终集中审美确认前不将 G05 标记为视觉冻结。
|
||||
@@ -1,81 +0,0 @@
|
||||
# G08–G10 入谱申请链路设计记录
|
||||
|
||||
> 日期:2026-07-14
|
||||
> 状态:页面候选、本地模拟交互与本轮 Product Design 截图审计已完成;标记为 `[~]`,等待后续逐页视觉审核。
|
||||
|
||||
## 1. Product Design 工作流
|
||||
|
||||
- `Product Design:index`:将工作路由为现有产品的上下文确认、截图审计和实现后设计检查。
|
||||
- `Product Design:audit`:本轮重新捕获 G08–G10 的表单、成功、列表、空态和 412 宽屏,不使用旧通用壳图冒充最终证据。
|
||||
- ImageGen:生成 `application-record-card.png`,并在截图审计发现假按钮后精确编辑出 `application-status-card.png`。
|
||||
- TDD:`tests/g08-g10-application-flow-contract.ps1` 与运行冒烟先因通用壳和缺少同页状态红灯,再由最小实现转绿。
|
||||
|
||||
## 2. 思维导图与外部参考
|
||||
|
||||
已查看 `C:\Users\Rain\Desktop\软件\JOB\app设计\思维导图.png` 与 `加入家谱.png`。外部参考的“加入家谱”依赖邀请码,但当前 OpenAPI 没有邀请码端点,因此不采用该流程。
|
||||
|
||||
当前申请链路唯一结构是:G06 公开家谱检索 → G08 填写真实身份与关系 → G09 查看自己的全部申请 → G10 管理员审核。三个阶段是不同用户任务,保留三条路由;各自的加载、空、失败、提交成功和审核结果只作为原路由状态,不再拆页。
|
||||
|
||||
## 3. 页面与状态
|
||||
|
||||
| 页面 | 独立任务 | 同页状态 |
|
||||
| --- | --- | --- |
|
||||
| G08 | 对某一 `genealogyId` 提交加入申请 | `form`、提交中、`success`、`error` |
|
||||
| G09 | 当前用户查看全部申请 | `loading`、`list`、`empty`、`error`;`PENDING/APPROVED/REJECTED` 为卡内状态 |
|
||||
| G10 | 当前家谱管理员审核待处理申请 | `loading`、`list`、`empty`、`error`;审核确认与结果留在卡内 |
|
||||
|
||||
G07/G08/G09 的旧 `ModulePage` 目录条目已经删除;G07 结果态继续只归 G06。
|
||||
|
||||
## 4. OpenAPI 功能参考
|
||||
|
||||
| 页面动作 | OpenAPI |
|
||||
| --- | --- |
|
||||
| G08 提交 | `POST /genealogy/app/genealogies/{genealogyId}/join-applies` |
|
||||
| G09 我的申请 | `GET /genealogy/app/genealogies/join-applies/mine` |
|
||||
| G09 撤销边界 | `DELETE /genealogy/app/genealogies/join-applies/{applyId}`;本轮页面不展示未接入的撤销按钮 |
|
||||
| G10 待审核列表 | `GET /genealogy/app/genealogies/{genealogyId}/join-applies/pending` |
|
||||
| G10 审核 | `PUT /genealogy/app/genealogies/{genealogyId}/join-applies/{applyId}/audit` |
|
||||
|
||||
OpenAPI 的申请体仍是 `JsonObject`,页面只使用已有业务能解释的 `realName`、`relation`、`message`,没有臆造邀请码、推荐人层级或多管理员审批字段。本阶段只据此安排字段、状态和动作位置;三页均使用页面内模拟数据与交互,不调用 `appApi`,不进行接口对接。
|
||||
|
||||
## 5. 视觉资产
|
||||
|
||||
- G08 复用 `g03-create-flow-panel.png` 的完整宣纸表单面,以及 `g06-search-input-wide.png` 的完整输入框皮肤;没有复制近似资产。
|
||||
- `application-record-card.png`:1050×360、24 位 RGB、不透明;右下留双审核槽,只供 G10 列表。
|
||||
- `application-status-card.png`:1050×360、24 位 RGB、不透明;移除双审核槽,供 G09 列表和 G09/G10 空/失败状态。
|
||||
- 主次操作分别使用 `a01-primary-button.png` 与 `a01-secondary-button.png`,CSS 只负责位置、文字和点击层。
|
||||
|
||||
## 6. 截图证据
|
||||
|
||||
- G08:`screens/runtime/2026-07-14/g-module-audit/applications/01-g08-form-360x800.png`、`02-g08-success-360x800.png`、`03-g08-form-412x915.png`。
|
||||
- G09:`04-g09-list-360x800.png`、`05-g09-empty-360x800.png`。
|
||||
- G10:`06-g10-list-360x800.png`、`07-g10-empty-360x800.png`、`08-g10-list-412x915.png`。
|
||||
- 同画布对照:`09-g08-form-success-comparison.png`、`10-g09-list-empty-comparison.png`、`11-g10-list-empty-comparison.png`。
|
||||
|
||||
## 7. Product Design 审计结果
|
||||
|
||||
### 强项
|
||||
|
||||
1. G08 表单在 360/412 下均完整显示,姓名、关系、说明和提交按钮顺序清楚;提交后仍在 G08 显示成功结果。
|
||||
2. G09 的申请状态以审核中、已通过、未通过映射接口枚举;通过项才可进入家谱。
|
||||
3. G10 的拒绝/通过使用主次完整位图按钮,动作前有确认弹窗,避免误审。
|
||||
4. 三页的空态均留在原路由,没有新增状态页。
|
||||
|
||||
### 已修复问题
|
||||
|
||||
- P2:第一张共享卡为审核按钮预留了两个槽,放到 G09 和空态时形成假按钮。已生成无按钮槽的状态卡并重新截图,问题消失。
|
||||
- P2:运行冒烟最初在面板出现后立即查找原生输入,可能早于 `uni-input` 内部节点渲染。测试改为等待真实输入节点,再填写和点击;真实提交已到达同页成功态。
|
||||
|
||||
### 可访问性边界
|
||||
|
||||
- 截图可确认主按钮、审核按钮、表单顺序和文字未裁切。
|
||||
- G10 审核按钮在 360 下仍有完整卡内点击区域,但最终 Android 真机仍需验证触摸目标、字体放大和读屏标签。
|
||||
- 截图不能证明弹窗焦点管理或读屏状态播报,不宣称完整无障碍合规。
|
||||
|
||||
## 8. 验证
|
||||
|
||||
- `G08-G10-APPLICATION-FLOW-CONTRACT PASS`
|
||||
- `G08-G10-APPLICATION-FLOW-RUNTIME-SMOKE PASS`,包含真实控件填写与本地模拟提交
|
||||
- `PASS compile audit`
|
||||
|
||||
三页仅标记为候选完成,用户集中看样式前不标记为视觉冻结。
|
||||
@@ -1,51 +0,0 @@
|
||||
# G11–G12 家谱设置与字辈诗设计记录
|
||||
|
||||
> 日期:2026-07-14
|
||||
> 状态:页面候选、同页状态、运行截图与 Product Design 对比审视已完成;标记为 `[~]`,等待用户后续逐页视觉审核。
|
||||
|
||||
## 1. 设计边界
|
||||
|
||||
- 继续使用项目已冻结的宣纸、朱砂、藏青与古金视觉体系,不照搬外部参考的颜色、卡片或按钮。
|
||||
- OpenAPI 只用于确认可设计的功能位置与数据语义,不导入 `appApi`、不新增 API 方法、不进行接口联调。
|
||||
- G11 与 G12 的空、失败、成功、编辑状态全部留在原路由,不为同一任务新增页面。
|
||||
- G11 不放置“转让管理权”假入口:该动作需要成员选择上下文,当前页面只安排名称、公开范围和访问说明。
|
||||
|
||||
## 2. 页面与状态
|
||||
|
||||
| 页面 | 核心任务 | 同页状态 |
|
||||
| --- | --- | --- |
|
||||
| G11 | 编辑家谱名称、公开范围、访问说明 | `loading`、`form`、`success`、`error` |
|
||||
| G12 | 查看逐代字辈、批量录入连续字辈 | `loading`、`list`、`empty`、`edit`、`error` |
|
||||
|
||||
G11 的公开范围提供“仅成员可见”和“公开可申请”两个明确选项,并给出权限解释。G12 采用外部思维导图可借鉴的逐世代行结构,但视觉表面仍完全使用本项目资产;编辑区保留接口清单所描述的连续 `content` 与“缺失旧世代时停止”策略位置。
|
||||
|
||||
## 3. 视觉资产
|
||||
|
||||
- 两页复用 `g03-create-flow-panel.png` 作为完整双金线宣纸面板。
|
||||
- 输入、字辈行使用 `g06-search-input-wide.png` 完整位图皮肤。
|
||||
- 主次操作分别使用 `a01-primary-button.png` 与 `a01-secondary-button.png`。
|
||||
- CSS 只负责布局、真实文字和点击层,不绘制卡片背景、边框、圆角或装饰。
|
||||
- 现有资产已能覆盖页面,因此本轮没有调用 ImageGen 新增近似资产。
|
||||
|
||||
## 4. 截图证据
|
||||
|
||||
目录:`screens/runtime/2026-07-14/g-module-audit/settings-poems/`
|
||||
|
||||
- 改前基线:`00-g11-before-360x800.png`、`00-g12-before-360x800.png`。
|
||||
- G11:`01-g11-form-360x800.png`、`02-g11-success-360x800.png`、`03-g11-form-412x915.png`。
|
||||
- G12:`04-g12-list-360x800.png`、`05-g12-empty-360x800.png`、`06-g12-edit-360x800.png`、`07-g12-list-412x915.png`。
|
||||
- 同画布对比:`08-g11-before-after-comparison.png`、`09-g12-before-after-comparison.png`。
|
||||
|
||||
## 5. Product Design 审视
|
||||
|
||||
- G11 相比通用设置壳,信息层级变为“设置目的 → 名称 → 公开范围及权限解释 → 访问说明 → 保存”,不再出现无法完成的管理权转让入口。
|
||||
- G12 相比通用详情壳,逐世代字辈可直接扫描,当前字辈使用朱砂强调;空态与编辑态仍保持同一页面语义。
|
||||
- 360 × 800 与 412 × 915 下,完整面板、按钮和底部山水均未被裁切;编辑态主次动作层级清楚。
|
||||
- 截图不能证明 Android 字体放大、读屏播报和真机触摸区域,留到最终真机验证阶段。
|
||||
|
||||
## 6. 验证
|
||||
|
||||
- `G11-G12-SETTINGS-POEMS-CONTRACT PASS`
|
||||
- `G11-G12-SETTINGS-POEMS-RUNTIME-SMOKE PASS`
|
||||
- 页面代码不引用 `@/utils/api.js` 或 `appApi`。
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user