Compare commits
101 Commits
1da4d8cf1f
...
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 | |||
| ff60119277 | |||
| 01d246c47b | |||
| 5a31a75da0 | |||
| db97d3da27 | |||
| 0d0645a112 | |||
| 3e3251a4fc | |||
| c848653758 | |||
| 0857e5dd13 | |||
| 887481a4ab | |||
| 23cfd365a1 | |||
| cb25317412 | |||
| 0dec90ffdd | |||
| 241a7af54c | |||
| 8c98936da5 | |||
| 1015e60ff7 | |||
| 355f47976a | |||
| 965fff1621 | |||
| 3d36fbba13 | |||
| 7fd66db51e | |||
| 778cd8e7bf | |||
| 83d58268ac | |||
| f7d6d3cfb1 | |||
| bd50f15d5c | |||
| a0207ba2cf | |||
| 5a3adbc5d0 | |||
| 225c5272b6 | |||
| b1b635c6ae | |||
| 134ae75c2b | |||
| 89dd20cec0 | |||
| bf4c6dc5fa | |||
| c41d155ffe | |||
| 86a41bcebc | |||
| 5507ba96b4 | |||
| f684ae3f92 | |||
| 1c9830c550 | |||
| 040c2cb37e | |||
| aacd503e2f | |||
| 35364f3818 | |||
| 974369587a | |||
| 086917224a | |||
| b61ee990cd | |||
| a68f2a51ad | |||
| 6db93d8c1b | |||
| 79c195cb0d | |||
| ab8f9384c3 | |||
| 00d4af2918 | |||
| b078d70b7f | |||
| 5b828eb0fd | |||
| 85ee447040 |
@@ -0,0 +1,67 @@
|
|||||||
|
######################################################################
|
||||||
|
# Build Tools
|
||||||
|
|
||||||
|
.gradle
|
||||||
|
/build/
|
||||||
|
!gradle/wrapper/gradle-wrapper.jar
|
||||||
|
|
||||||
|
target/
|
||||||
|
!.mvn/wrapper/maven-wrapper.jar
|
||||||
|
|
||||||
|
######################################################################
|
||||||
|
# IDE
|
||||||
|
|
||||||
|
### STS ###
|
||||||
|
.apt_generated
|
||||||
|
.classpath
|
||||||
|
.factorypath
|
||||||
|
.project
|
||||||
|
.settings
|
||||||
|
.springBeans
|
||||||
|
|
||||||
|
### IntelliJ IDEA ###
|
||||||
|
.idea
|
||||||
|
*.iws
|
||||||
|
*.iml
|
||||||
|
*.ipr
|
||||||
|
|
||||||
|
### JRebel ###
|
||||||
|
rebel.xml
|
||||||
|
|
||||||
|
### NetBeans ###
|
||||||
|
nbproject/private/
|
||||||
|
build/*
|
||||||
|
nbbuild/
|
||||||
|
nbdist/
|
||||||
|
.nb-gradle/
|
||||||
|
|
||||||
|
######################################################################
|
||||||
|
# Others
|
||||||
|
*.log
|
||||||
|
*.log.gz
|
||||||
|
*.xml.versionsBackup
|
||||||
|
*.swp
|
||||||
|
|
||||||
|
!*/build/*.java
|
||||||
|
!*/build/*.html
|
||||||
|
!*/build/*.xml
|
||||||
|
|
||||||
|
.flattened-pom.xml
|
||||||
|
/.maven-home/.msp/incrementMark
|
||||||
|
|
||||||
|
robots.txt
|
||||||
|
sitemap.xml
|
||||||
|
|
||||||
|
# Regenerable local output and audit caches
|
||||||
|
**/node_modules/
|
||||||
|
/tmp/
|
||||||
|
/unpackage/
|
||||||
|
/.vite/
|
||||||
|
/.audit/
|
||||||
|
/design-pipeline/generated/
|
||||||
|
/tmp-g01-icon-audit.png
|
||||||
|
/tmp-mumu-current.png
|
||||||
|
/artifacts/
|
||||||
|
/docs/audit-*/
|
||||||
|
/docs/audits/
|
||||||
|
/book-style-*.png
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"version" : "1.0",
|
||||||
|
"configurations" : [
|
||||||
|
{
|
||||||
|
"playground" : "standard",
|
||||||
|
"type" : "uni-app:app-android"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -1,11 +1,8 @@
|
|||||||
<script>
|
<script>
|
||||||
export default {
|
export default {}
|
||||||
onLaunch() {
|
|
||||||
console.log('家谱 App 已启动')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
|
// Global styles own the fixed-header host stacking contract.
|
||||||
@import './styles/global.scss';
|
@import './styles/global.scss';
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
<template>
|
||||||
|
<button
|
||||||
|
class="app-button"
|
||||||
|
:class="[
|
||||||
|
`app-button--${type}`,
|
||||||
|
{
|
||||||
|
'app-button--block': block,
|
||||||
|
'app-button--compact': compact,
|
||||||
|
'app-button--disabled': disabled,
|
||||||
|
},
|
||||||
|
]"
|
||||||
|
:disabled="disabled"
|
||||||
|
:aria-label="label"
|
||||||
|
:hover-class="disabled ? 'none' : 'app-button--pressed'"
|
||||||
|
@click="handleClick"
|
||||||
|
>
|
||||||
|
<image class="app-button__skin" :src="skin" mode="aspectFit" />
|
||||||
|
<text class="app-button__label app-single-line"
|
||||||
|
><slot>{{ label }}</slot></text
|
||||||
|
>
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed } from "vue";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
label: { type: String, default: "" },
|
||||||
|
type: { type: String, default: "primary" },
|
||||||
|
block: { type: Boolean, default: false },
|
||||||
|
compact: { type: Boolean, default: false },
|
||||||
|
disabled: { type: Boolean, default: false },
|
||||||
|
});
|
||||||
|
const emit = defineEmits(["click"]);
|
||||||
|
|
||||||
|
const skin = computed(() =>
|
||||||
|
props.type === "secondary"
|
||||||
|
? "/static/assets/foundation/transparent/scroll-secondary.png"
|
||||||
|
: "/static/assets/foundation/transparent/scroll-primary.png",
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleClick = (event) => {
|
||||||
|
if (!props.disabled) emit("click", event);
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
@use "../styles/adaptive-frame-profiles.scss" as adaptive;
|
||||||
|
|
||||||
|
.app-button {
|
||||||
|
display: inline-grid;
|
||||||
|
width: 420rpx;
|
||||||
|
max-width: 100%;
|
||||||
|
min-height: 88rpx;
|
||||||
|
place-items: center;
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
line-height: normal;
|
||||||
|
}
|
||||||
|
.app-button::after {
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
.app-button--block {
|
||||||
|
display: grid;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.app-button--compact {
|
||||||
|
}
|
||||||
|
.app-button--compact.app-button--primary {
|
||||||
|
@include adaptive.adaptive-scroll-button(primary);
|
||||||
|
}
|
||||||
|
.app-button--compact.app-button--secondary {
|
||||||
|
@include adaptive.adaptive-scroll-button(secondary);
|
||||||
|
}
|
||||||
|
.app-button--compact .app-button__skin {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.app-button--compact .app-button__label {
|
||||||
|
padding: 0;
|
||||||
|
font-size: clamp(14px, 23rpx, 17px);
|
||||||
|
letter-spacing: 0;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.app-button__skin {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.app-button__skin,
|
||||||
|
.app-button__label {
|
||||||
|
grid-area: 1 / 1;
|
||||||
|
}
|
||||||
|
.app-button__label {
|
||||||
|
z-index: 1;
|
||||||
|
padding: 12rpx 38rpx;
|
||||||
|
box-sizing: border-box;
|
||||||
|
color: #fffaf0;
|
||||||
|
font-size: clamp(16px, 29rpx, 20px);
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 3rpx;
|
||||||
|
line-height: 1.35;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.app-button--secondary .app-button__label {
|
||||||
|
color: #5c4330;
|
||||||
|
}
|
||||||
|
.app-button--disabled {
|
||||||
|
opacity: 0.48;
|
||||||
|
}
|
||||||
|
.app-button--pressed {
|
||||||
|
opacity: 0.78;
|
||||||
|
transform: translateY(1rpx);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
<template>
|
||||||
|
<view
|
||||||
|
v-if="visible"
|
||||||
|
class="app-dialog-layer"
|
||||||
|
@click="closeOnMask && cancel()"
|
||||||
|
>
|
||||||
|
<view
|
||||||
|
ref="dialogRef"
|
||||||
|
class="app-dialog"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
:aria-label="title"
|
||||||
|
tabindex="-1"
|
||||||
|
@click.stop
|
||||||
|
@keydown.esc.stop.prevent="cancel"
|
||||||
|
@keydown.tab="trapFocus"
|
||||||
|
>
|
||||||
|
<scroll-view class="app-dialog__content" scroll-y>
|
||||||
|
<view class="app-dialog__copy">
|
||||||
|
<text v-if="eyebrow" class="app-dialog__eyebrow">{{ eyebrow }}</text>
|
||||||
|
<text class="app-dialog__title">{{ title }}</text>
|
||||||
|
<text v-if="message" class="app-dialog__message">{{ message }}</text>
|
||||||
|
</view>
|
||||||
|
<slot />
|
||||||
|
<view
|
||||||
|
class="app-dialog__actions"
|
||||||
|
:class="{ 'app-dialog__actions--single': !showCancel }"
|
||||||
|
>
|
||||||
|
<AppButton
|
||||||
|
v-if="showCancel"
|
||||||
|
type="secondary"
|
||||||
|
:label="cancelText"
|
||||||
|
:compact="compactActions && showCancel"
|
||||||
|
@click="cancel"
|
||||||
|
/>
|
||||||
|
<AppButton
|
||||||
|
:label="confirmText"
|
||||||
|
:compact="compactActions && showCancel"
|
||||||
|
@click="$emit('confirm')"
|
||||||
|
/>
|
||||||
|
</view>
|
||||||
|
</scroll-view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { nextTick, ref, watch } from "vue";
|
||||||
|
import AppButton from "@/components/AppButton.vue";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
visible: { type: Boolean, default: false },
|
||||||
|
eyebrow: { type: String, default: "" },
|
||||||
|
title: { type: String, required: true },
|
||||||
|
message: { type: String, default: "" },
|
||||||
|
confirmText: { type: String, default: "我知道了" },
|
||||||
|
cancelText: { type: String, default: "取消" },
|
||||||
|
showCancel: { type: Boolean, default: false },
|
||||||
|
compactActions: { type: Boolean, default: false },
|
||||||
|
closeOnMask: { type: Boolean, default: true },
|
||||||
|
});
|
||||||
|
const emit = defineEmits(["confirm", "cancel", "close"]);
|
||||||
|
const dialogRef = ref(null);
|
||||||
|
let previousFocus = null;
|
||||||
|
|
||||||
|
const getDialogElement = () => {
|
||||||
|
const target = dialogRef.value;
|
||||||
|
if (target && typeof target.querySelectorAll === "function") return target;
|
||||||
|
if (target?.$el && typeof target.$el.querySelectorAll === "function") return target.$el;
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const trapFocus = (event) => {
|
||||||
|
if (typeof document === "undefined") return;
|
||||||
|
const dialog = getDialogElement() || event.currentTarget;
|
||||||
|
if (!dialog || typeof dialog.querySelectorAll !== "function") return;
|
||||||
|
const focusable = Array.from(
|
||||||
|
dialog.querySelectorAll(
|
||||||
|
'button:not([disabled]), [href], input:not([disabled]), textarea:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])',
|
||||||
|
),
|
||||||
|
).filter((element) => element.offsetParent !== null);
|
||||||
|
if (focusable.length === 0) {
|
||||||
|
event.preventDefault();
|
||||||
|
dialog.focus?.();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const first = focusable[0];
|
||||||
|
const last = focusable[focusable.length - 1];
|
||||||
|
if (event.shiftKey && (document.activeElement === first || document.activeElement === dialog)) {
|
||||||
|
event.preventDefault();
|
||||||
|
last.focus();
|
||||||
|
} else if (!event.shiftKey && document.activeElement === last) {
|
||||||
|
event.preventDefault();
|
||||||
|
first.focus();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.visible,
|
||||||
|
async (visible) => {
|
||||||
|
if (typeof document === "undefined") return;
|
||||||
|
if (visible) {
|
||||||
|
previousFocus = document.activeElement;
|
||||||
|
await nextTick();
|
||||||
|
getDialogElement()?.focus?.();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
previousFocus?.focus?.();
|
||||||
|
previousFocus = null;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const cancel = () => {
|
||||||
|
emit("cancel");
|
||||||
|
emit("close");
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
@use "../styles/adaptive-frame-profiles.scss" as adaptive;
|
||||||
|
|
||||||
|
.app-dialog-layer {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 80;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: calc(40rpx + env(safe-area-inset-top)) 40rpx calc(40rpx + env(safe-area-inset-bottom));
|
||||||
|
background: rgba(35, 18, 10, 0.62);
|
||||||
|
}
|
||||||
|
.app-dialog {
|
||||||
|
display: flex;
|
||||||
|
width: 650rpx;
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: calc(var(--app-viewport-height, 100vh) - 80rpx - env(safe-area-inset-top) - env(safe-area-inset-bottom));
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
@include adaptive.adaptive-auth-dialog;
|
||||||
|
}
|
||||||
|
.app-dialog__content {
|
||||||
|
z-index: 1;
|
||||||
|
display: flex;
|
||||||
|
min-height: 0;
|
||||||
|
flex: 1;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 16rpx 20rpx 12rpx;
|
||||||
|
text-align: center;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
.app-dialog__copy {
|
||||||
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.app-dialog__title,
|
||||||
|
.app-dialog__message {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.app-dialog__eyebrow {
|
||||||
|
color: #9f170f;
|
||||||
|
font-size: clamp(14px, 22rpx, 17px);
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 4rpx;
|
||||||
|
}
|
||||||
|
.app-dialog__title {
|
||||||
|
color: #8f160f;
|
||||||
|
font-size: clamp(22px, 42rpx, 28px);
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 4rpx;
|
||||||
|
}
|
||||||
|
.app-dialog__eyebrow + .app-dialog__title {
|
||||||
|
margin-top: 10rpx;
|
||||||
|
}
|
||||||
|
.app-dialog__message {
|
||||||
|
margin-top: 22rpx;
|
||||||
|
color: #513a28;
|
||||||
|
font-size: clamp(16px, 27rpx, 20px);
|
||||||
|
line-height: 1.65;
|
||||||
|
}
|
||||||
|
.app-dialog__actions {
|
||||||
|
display: grid;
|
||||||
|
width: 100%;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 16rpx;
|
||||||
|
margin-top: 26rpx;
|
||||||
|
}
|
||||||
|
.app-dialog__actions :deep(.app-button__label) {
|
||||||
|
padding-right: 18rpx;
|
||||||
|
padding-left: 18rpx;
|
||||||
|
}
|
||||||
|
.app-dialog__actions--single {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.app-dialog__actions .app-button {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 82rpx;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
<template>
|
||||||
|
<view
|
||||||
|
class="app-loading"
|
||||||
|
:class="`app-loading--${variant}`"
|
||||||
|
role="status"
|
||||||
|
aria-live="polite"
|
||||||
|
aria-busy="true"
|
||||||
|
>
|
||||||
|
<view class="app-loading__emblem" aria-hidden="true">
|
||||||
|
<image
|
||||||
|
class="app-loading__seal"
|
||||||
|
src="/static/assets/foundation/transparent/brand-seal.png"
|
||||||
|
mode="aspectFit"
|
||||||
|
/>
|
||||||
|
<image
|
||||||
|
class="app-loading__knot"
|
||||||
|
src="/static/assets/foundation/transparent/auth-divider-knot.png"
|
||||||
|
mode="aspectFit"
|
||||||
|
/>
|
||||||
|
</view>
|
||||||
|
<text class="app-loading__copy">{{ text }}</text>
|
||||||
|
<text v-if="description" class="app-loading__description">{{
|
||||||
|
description
|
||||||
|
}}</text>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
defineProps({
|
||||||
|
variant: {
|
||||||
|
type: String,
|
||||||
|
default: "page",
|
||||||
|
validator: (value) => ["page", "section"].includes(value),
|
||||||
|
},
|
||||||
|
text: { type: String, default: "正在展开,请稍候…" },
|
||||||
|
description: { type: String, default: "" },
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.app-loading {
|
||||||
|
z-index: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: #62584c;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.app-loading__emblem {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.app-loading__seal {
|
||||||
|
animation: app-loading-seal-breathe 1.6s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
.app-loading__knot {
|
||||||
|
margin-top: 8rpx;
|
||||||
|
opacity: 0.72;
|
||||||
|
animation: app-loading-knot-breathe 1.6s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
.app-loading__copy {
|
||||||
|
font-weight: 500;
|
||||||
|
letter-spacing: 2rpx;
|
||||||
|
}
|
||||||
|
.app-loading__description {
|
||||||
|
color: #62584c;
|
||||||
|
line-height: 1.65;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-loading--page {
|
||||||
|
min-height: 320rpx;
|
||||||
|
}
|
||||||
|
.app-loading--page .app-loading__seal {
|
||||||
|
width: 132rpx;
|
||||||
|
height: 136rpx;
|
||||||
|
}
|
||||||
|
.app-loading--page .app-loading__knot {
|
||||||
|
width: 56rpx;
|
||||||
|
height: 18rpx;
|
||||||
|
}
|
||||||
|
.app-loading--page .app-loading__copy {
|
||||||
|
margin-top: 22rpx;
|
||||||
|
font-size: clamp(17px, 30rpx, 22px);
|
||||||
|
}
|
||||||
|
.app-loading--page .app-loading__description {
|
||||||
|
margin-top: 14rpx;
|
||||||
|
font-size: clamp(15px, 24rpx, 18px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-loading--section {
|
||||||
|
min-height: 180rpx;
|
||||||
|
}
|
||||||
|
.app-loading--section .app-loading__seal {
|
||||||
|
width: 88rpx;
|
||||||
|
height: 90rpx;
|
||||||
|
}
|
||||||
|
.app-loading--section .app-loading__knot {
|
||||||
|
width: 42rpx;
|
||||||
|
height: 14rpx;
|
||||||
|
margin-top: 5rpx;
|
||||||
|
}
|
||||||
|
.app-loading--section .app-loading__copy {
|
||||||
|
margin-top: 14rpx;
|
||||||
|
font-size: clamp(15px, 24rpx, 18px);
|
||||||
|
}
|
||||||
|
.app-loading--section .app-loading__description {
|
||||||
|
margin-top: 10rpx;
|
||||||
|
font-size: clamp(14px, 22rpx, 17px);
|
||||||
|
}
|
||||||
|
@keyframes app-loading-seal-breathe {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
opacity: 0.82;
|
||||||
|
transform: scale(0.96);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@keyframes app-loading-knot-breathe {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
opacity: 0.44;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 0.76;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@keyframes app-loading-seal-essential-pulse {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
opacity: 0.58;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@keyframes app-loading-knot-essential-pulse {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
opacity: 0.28;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 0.86;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.app-loading__seal {
|
||||||
|
animation: none;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
.app-loading__knot {
|
||||||
|
animation: none;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -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>
|
||||||
@@ -1,24 +1,70 @@
|
|||||||
<template>
|
<template>
|
||||||
<view class="app-tabbar">
|
<view
|
||||||
<view v-for="item in items" :key="item.key" class="tab-item" @click="switchTab(item)">
|
class="app-tabbar"
|
||||||
<image class="tab-icon" :src="active === item.key ? item.activeIcon : item.icon" mode="aspectFit" />
|
:class="{ 'app-tabbar--light': tone === 'light' }"
|
||||||
<text :class="['tab-label', { active: active === item.key }]">{{ item.label }}</text>
|
role="tablist"
|
||||||
</view>
|
aria-label="主要导航"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
v-for="item in items"
|
||||||
|
:key="item.key"
|
||||||
|
class="tab-item"
|
||||||
|
role="tab"
|
||||||
|
:aria-selected="active === item.key"
|
||||||
|
:aria-label="`${item.label}${active === item.key ? ',当前页面' : ''}`"
|
||||||
|
hover-class="tab-item--pressed"
|
||||||
|
@click="switchRoot(item)"
|
||||||
|
>
|
||||||
|
<image
|
||||||
|
class="tab-icon"
|
||||||
|
:src="active === item.key ? item.activeIcon : item.icon"
|
||||||
|
mode="aspectFit"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<text :class="['tab-label', 'app-single-line', { active: active === item.key }]">{{
|
||||||
|
item.label
|
||||||
|
}}</text>
|
||||||
|
</button>
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
const props = defineProps({ active: { type: String, required: true } })
|
import { goRoot } from "@/utils/navigation/gateway.js";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
active: { type: String, required: true },
|
||||||
|
tone: { type: String, default: "default" },
|
||||||
|
});
|
||||||
|
|
||||||
const items = [
|
const items = [
|
||||||
{ key: 'genealogy', label: '家谱', path: '/pages/genealogy/index', icon: '/static/assets/icons/tab/genealogy-v4.png', activeIcon: '/static/assets/icons/tab/genealogy-active-v4.png' },
|
{
|
||||||
{ key: 'family', label: '家族', path: '/pages/family/index', icon: '/static/assets/icons/tab/family-v4.png', activeIcon: '/static/assets/icons/tab/family-active-v4.png' },
|
key: "genealogy",
|
||||||
{ key: 'profile', label: '我的', path: '/pages/profile/index', icon: '/static/assets/icons/tab/profile-v4.png', activeIcon: '/static/assets/icons/tab/profile-active-v4.png' }
|
label: "家谱",
|
||||||
]
|
routeKey: "G01",
|
||||||
|
icon: "/static/assets/foundation/transparent/tab-genealogy.png",
|
||||||
|
activeIcon:
|
||||||
|
"/static/assets/foundation/transparent/tab-genealogy-active.png",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "family",
|
||||||
|
label: "消息",
|
||||||
|
routeKey: "F01",
|
||||||
|
icon: "/static/assets/foundation/transparent/tab-family.png",
|
||||||
|
activeIcon: "/static/assets/foundation/transparent/tab-family-active.png",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "profile",
|
||||||
|
label: "我的",
|
||||||
|
routeKey: "M01",
|
||||||
|
icon: "/static/assets/foundation/transparent/tab-profile.png",
|
||||||
|
activeIcon: "/static/assets/foundation/transparent/tab-profile-active.png",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
const switchTab = (item) => {
|
const switchRoot = (item) => {
|
||||||
if (item.key !== props.active) uni.reLaunch({ url: item.path })
|
if (item.key === props.active) return;
|
||||||
}
|
return goRoot(item.routeKey);
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
@@ -29,36 +75,58 @@ const switchTab = (item) => {
|
|||||||
left: 0;
|
left: 0;
|
||||||
z-index: 20;
|
z-index: 20;
|
||||||
display: flex;
|
display: flex;
|
||||||
height: 128rpx;
|
min-height: 112rpx;
|
||||||
padding-bottom: env(safe-area-inset-bottom);
|
padding-bottom: env(safe-area-inset-bottom);
|
||||||
box-sizing: content-box;
|
box-sizing: content-box;
|
||||||
border-top: 1rpx solid $gold-soft;
|
border-top: 1rpx solid $gold-soft;
|
||||||
background: $paper-white;
|
background: $paper-white;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.app-tabbar--light {
|
||||||
|
border-top-color: #e5dac4;
|
||||||
|
background: #fffdf9;
|
||||||
|
}
|
||||||
|
|
||||||
.tab-item {
|
.tab-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
min-height: 96rpx;
|
min-width: 0;
|
||||||
|
min-height: 88rpx;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding: 10rpx 0 8rpx;
|
margin: 0;
|
||||||
|
padding: 4rpx 0;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 0;
|
||||||
|
background: transparent;
|
||||||
|
line-height: normal;
|
||||||
|
}
|
||||||
|
.tab-item::after {
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
.tab-item--pressed {
|
||||||
|
opacity: 0.72;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tab-icon {
|
.tab-icon {
|
||||||
width: 56rpx;
|
width: 64rpx;
|
||||||
height: 56rpx;
|
height: 64rpx;
|
||||||
margin-bottom: 5rpx;
|
margin-bottom: 2rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tab-label {
|
.tab-label {
|
||||||
|
max-width: 100%;
|
||||||
color: $ink-muted;
|
color: $ink-muted;
|
||||||
font-size: 28rpx;
|
font-size: clamp(17px, 30rpx, 22px);
|
||||||
letter-spacing: 2rpx;
|
letter-spacing: 2rpx;
|
||||||
line-height: 1.2;
|
line-height: 1.2;
|
||||||
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tab-label.active { color: $brand-red; font-weight: 700; }
|
.tab-label.active {
|
||||||
|
color: $brand-red;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
<template>
|
||||||
|
<view
|
||||||
|
v-show="visible"
|
||||||
|
class="app-toast"
|
||||||
|
role="status"
|
||||||
|
aria-live="polite"
|
||||||
|
aria-atomic="true"
|
||||||
|
>
|
||||||
|
<text class="app-toast__copy">{{ message }}</text>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
defineProps({
|
||||||
|
visible: { type: Boolean, default: false },
|
||||||
|
message: { type: String, default: "" },
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
@use "../styles/adaptive-frame-profiles.scss" as adaptive;
|
||||||
|
|
||||||
|
.app-toast {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 100;
|
||||||
|
top: calc(env(safe-area-inset-top) + 24rpx);
|
||||||
|
left: 50%;
|
||||||
|
display: flex;
|
||||||
|
width: 590rpx;
|
||||||
|
max-width: calc(100vw - 48rpx);
|
||||||
|
min-height: 82rpx;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
@include adaptive.adaptive-feedback-toast;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.app-toast__copy {
|
||||||
|
z-index: 1;
|
||||||
|
padding: 14rpx 30rpx;
|
||||||
|
color: #5c4330;
|
||||||
|
font-size: clamp(15px, 25rpx, 18px);
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 1.45;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -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,138 +0,0 @@
|
|||||||
<template>
|
|
||||||
<view class="genealogy-card" :class="{ 'genealogy-card--current': selected }" @click="$emit('select', genealogy)">
|
|
||||||
<image class="row-frame" src="/static/assets/backgrounds/genealogy-list-slip-frame.png" mode="scaleToFill" />
|
|
||||||
<view class="surname-seal">
|
|
||||||
<image class="surname-seal-frame" src="/static/assets/icons/genealogy/seal-row-frame-v1.png" mode="scaleToFill" />
|
|
||||||
<view class="surname-seal-copy">
|
|
||||||
<text class="seal-title">家谱</text>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
|
|
||||||
<view class="card-copy">
|
|
||||||
<text class="card-name">{{ genealogy.name }}</text>
|
|
||||||
<view class="card-detail-row">
|
|
||||||
<view class="card-metas">
|
|
||||||
<view class="card-meta-item">
|
|
||||||
<image class="card-meta-icon" src="/static/assets/icons/common/location-v1.png" mode="aspectFit" />
|
|
||||||
<text class="card-meta">{{ genealogy.location }}</text>
|
|
||||||
</view>
|
|
||||||
<view class="card-meta-item">
|
|
||||||
<image class="card-meta-icon" src="/static/assets/icons/common/member-meta-v1.png" mode="aspectFit" />
|
|
||||||
<text class="card-meta">{{ genealogy.memberCount }} 位成员</text>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
<view class="card-updated">
|
|
||||||
<text>更新于 {{ genealogy.updatedAt }}</text>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
|
|
||||||
<view class="card-side">
|
|
||||||
<text class="card-role">{{ role }}</text>
|
|
||||||
<image class="card-chevron" src="/static/assets/icons/common/chevron-right-v2.png" mode="aspectFit" />
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
defineProps({
|
|
||||||
genealogy: { type: Object, required: true },
|
|
||||||
role: { type: String, required: true },
|
|
||||||
selected: { type: Boolean, default: false }
|
|
||||||
})
|
|
||||||
defineEmits(['select'])
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped lang="scss">
|
|
||||||
.genealogy-card {
|
|
||||||
position: relative;
|
|
||||||
display: flex;
|
|
||||||
min-height: 160rpx;
|
|
||||||
align-items: center;
|
|
||||||
padding: 20rpx 24rpx;
|
|
||||||
box-sizing: border-box;
|
|
||||||
background: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.genealogy-card--current { background: transparent; }
|
|
||||||
|
|
||||||
.row-frame {
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
right: 0;
|
|
||||||
bottom: 0;
|
|
||||||
left: 0;
|
|
||||||
z-index: 0;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.surname-seal {
|
|
||||||
position: relative;
|
|
||||||
z-index: 1;
|
|
||||||
display: flex;
|
|
||||||
width: 76rpx;
|
|
||||||
height: 118rpx;
|
|
||||||
flex: 0 0 auto;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
margin-right: 22rpx;
|
|
||||||
color: #fff5df;
|
|
||||||
}
|
|
||||||
|
|
||||||
.surname-seal-frame { position: absolute; top: 0; right: 0; bottom: 0; left: 0; z-index: 0; width: 100%; height: 100%; }
|
|
||||||
.surname-seal-copy { position: relative; z-index: 1; display: flex; height: 82rpx; align-items: center; justify-content: center; }
|
|
||||||
.seal-title { color: #fff5df; font-family: 'STKaiti', 'KaiTi', serif; font-size: 30rpx; font-weight: 700; letter-spacing: 2rpx; line-height: 1; writing-mode: vertical-rl; }
|
|
||||||
.genealogy-card:not(.genealogy-card--current) .surname-seal-frame { opacity: .32; }
|
|
||||||
.genealogy-card:not(.genealogy-card--current) .seal-title { color: $ink-muted; }
|
|
||||||
|
|
||||||
.card-copy {
|
|
||||||
position: relative;
|
|
||||||
z-index: 1;
|
|
||||||
display: flex;
|
|
||||||
min-width: 0;
|
|
||||||
flex: 1;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-name,
|
|
||||||
.card-meta {
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-name { color: $ink; font-family: 'STKaiti', 'KaiTi', serif; font-size: 37rpx; font-weight: 700; }
|
|
||||||
.card-detail-row { display: flex; min-width: 0; align-items: center; justify-content: space-between; margin-top: 12rpx; }
|
|
||||||
.card-metas { display: flex; min-width: 0; align-items: center; }
|
|
||||||
.card-meta-item { display: flex; min-width: 0; align-items: center; margin-right: 14rpx; }
|
|
||||||
.card-meta-icon { width: 24rpx; height: 24rpx; margin-right: 5rpx; }
|
|
||||||
.card-meta { color: $ink-muted; font-size: 22rpx; }
|
|
||||||
.card-updated { display: flex; flex: 0 0 auto; align-items: center; margin-left: 10rpx; color: $ink-muted; font-size: 20rpx; white-space: nowrap; }
|
|
||||||
|
|
||||||
.card-side {
|
|
||||||
position: relative;
|
|
||||||
z-index: 1;
|
|
||||||
display: flex;
|
|
||||||
width: 124rpx;
|
|
||||||
flex: 0 0 auto;
|
|
||||||
flex-direction: row;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: flex-end;
|
|
||||||
margin-left: 10rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-role { color: $ink-muted; font-size: 24rpx; white-space: nowrap; }
|
|
||||||
.card-chevron { width: 34rpx; height: 34rpx; margin-left: 14rpx; }
|
|
||||||
|
|
||||||
@media screen and (max-width: 340px) {
|
|
||||||
.genealogy-card { min-height: 148rpx; padding-right: 18rpx; padding-left: 18rpx; }
|
|
||||||
.surname-seal { width: 68rpx; height: 106rpx; margin-right: 16rpx; }
|
|
||||||
.card-name { font-size: 32rpx; }
|
|
||||||
.card-meta { font-size: 20rpx; }
|
|
||||||
.card-updated { display: none; }
|
|
||||||
.card-side { width: 92rpx; margin-left: 8rpx; }
|
|
||||||
.card-chevron { width: 28rpx; height: 28rpx; margin-left: 8rpx; }
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
<template>
|
||||||
|
<view
|
||||||
|
class="module-page-background"
|
||||||
|
:class="`module-page-background--${module}`"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<image
|
||||||
|
class="module-page-background__image"
|
||||||
|
:src="source"
|
||||||
|
mode="aspectFill"
|
||||||
|
/>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed } from "vue";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
module: { type: String, required: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const sources = {
|
||||||
|
tree: "/static/assets/modules/tree/opaque/tree-page-background-long.png",
|
||||||
|
family:
|
||||||
|
"/static/assets/modules/family/opaque/family-page-background-long.png",
|
||||||
|
records:
|
||||||
|
"/static/assets/modules/records/opaque/records-page-background-long.png",
|
||||||
|
notification:
|
||||||
|
"/static/assets/modules/notification/opaque/notification-page-background-long.png",
|
||||||
|
profile:
|
||||||
|
"/static/assets/modules/profile/opaque/profile-page-background-long.png",
|
||||||
|
};
|
||||||
|
const source = computed(() => sources[props.module] || sources.profile);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.module-page-background {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 0;
|
||||||
|
inset: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #f5eee2;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.module-page-background__image {
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
opacity: 0.36;
|
||||||
|
}
|
||||||
|
.module-page-background--family .module-page-background__image {
|
||||||
|
opacity: 0.32;
|
||||||
|
}
|
||||||
|
.module-page-background--records .module-page-background__image {
|
||||||
|
opacity: 0.3;
|
||||||
|
}
|
||||||
|
.module-page-background--notification .module-page-background__image {
|
||||||
|
opacity: 0.28;
|
||||||
|
}
|
||||||
|
.module-page-background--profile .module-page-background__image {
|
||||||
|
opacity: 0.3;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,54 +1,151 @@
|
|||||||
<template>
|
<template>
|
||||||
|
<view class="page-header-slot" :class="{ 'page-header-slot--root': root }">
|
||||||
<view class="page-header" :class="{ 'page-header--root': root }">
|
<view class="page-header" :class="{ 'page-header--root': root }">
|
||||||
<image v-if="root" class="header-texture" src="/static/assets/backgrounds/header-cinnabar-texture-v2.jpg" mode="scaleToFill" />
|
<!-- 根页头部使用不透明朱砂底图与透明祠堂线稿两层完整资产。 -->
|
||||||
<image v-if="root" class="header-hall" src="/static/assets/backgrounds/header-hall-lineart.png" mode="aspectFit" />
|
<image
|
||||||
|
v-if="root"
|
||||||
|
class="header-texture"
|
||||||
|
src="/static/assets/foundation/opaque/root-header-cinnabar.jpg"
|
||||||
|
mode="scaleToFill"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<image
|
||||||
|
v-if="root"
|
||||||
|
class="header-hall"
|
||||||
|
src="/static/assets/foundation/transparent/root-header-hall.png"
|
||||||
|
mode="aspectFit"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
<view class="header-side header-side--left">
|
<view class="header-side header-side--left">
|
||||||
<view v-if="root" class="header-icon-button" @click="$emit('brand')">
|
<view
|
||||||
<image class="header-logo" src="/static/assets/icons/brand/jiapu-seal-logo-header.png" mode="aspectFit" />
|
v-if="root"
|
||||||
|
class="header-brand"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<image
|
||||||
|
class="header-logo"
|
||||||
|
src="/static/assets/foundation/transparent/brand-seal.png"
|
||||||
|
mode="aspectFit"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
</view>
|
</view>
|
||||||
<view v-else class="header-back" @click="goBack">{{ backLabel }}</view>
|
<button
|
||||||
|
v-else
|
||||||
|
class="header-back"
|
||||||
|
aria-label="返回上一页"
|
||||||
|
hover-class="header-back--pressed"
|
||||||
|
@click="handleBack"
|
||||||
|
>
|
||||||
|
<image
|
||||||
|
class="header-back__icon"
|
||||||
|
src="/static/assets/foundation/transparent/chevron-right.png"
|
||||||
|
mode="aspectFit"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<text class="header-title">{{ title }}</text>
|
<view
|
||||||
|
class="header-project-lockup"
|
||||||
|
:class="{ 'header-project-lockup--root': root }"
|
||||||
|
>
|
||||||
|
<image
|
||||||
|
v-if="!root"
|
||||||
|
class="header-project-lockup__logo"
|
||||||
|
src="/static/assets/foundation/transparent/brand-seal.png"
|
||||||
|
mode="aspectFit"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<text class="header-project-name">代代相传</text>
|
||||||
|
</view>
|
||||||
|
<text class="header-title app-single-line">{{ title }}</text>
|
||||||
|
|
||||||
<view class="header-side header-side--right">
|
<view class="header-side header-side--right">
|
||||||
<view v-if="root" class="header-icon-button header-notice" @click="$emit('notice')">
|
<button
|
||||||
<image class="header-notice-icon" src="/static/assets/icons/common/notice-v3.png" mode="aspectFit" />
|
v-if="root && notice"
|
||||||
|
class="header-icon-button header-notice"
|
||||||
|
:aria-label="unreadCount > 0 ? `打开消息中心,${unreadCount} 条未读` : '打开消息中心'"
|
||||||
|
@click="$emit('notice')"
|
||||||
|
>
|
||||||
|
<image
|
||||||
|
class="header-notice-icon"
|
||||||
|
src="/static/assets/foundation/transparent/notice.png"
|
||||||
|
mode="aspectFit"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
<view v-if="unreadCount > 0" class="notice-dot"></view>
|
<view v-if="unreadCount > 0" class="notice-dot"></view>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-else-if="root && action"
|
||||||
|
class="header-action header-action--root app-single-line"
|
||||||
|
@click="$emit('action')"
|
||||||
|
>{{ action }}</button>
|
||||||
|
<button v-else-if="action" class="header-action app-single-line" @click="$emit('action')">{{
|
||||||
|
action
|
||||||
|
}}</button>
|
||||||
|
<view
|
||||||
|
v-else
|
||||||
|
class="header-action-placeholder"
|
||||||
|
aria-hidden="true"
|
||||||
|
></view>
|
||||||
</view>
|
</view>
|
||||||
<view v-else class="header-action" @click="$emit('action')">{{ action }}</view>
|
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
|
import { goBack } from "@/utils/navigation/gateway.js";
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
title: { type: String, required: true },
|
title: { type: String, required: true },
|
||||||
action: { type: String, default: '' },
|
action: { type: String, default: "" },
|
||||||
backLabel: { type: String, default: '返回' },
|
|
||||||
root: { type: Boolean, default: false },
|
root: { type: Boolean, default: false },
|
||||||
unreadCount: { type: Number, default: 0 }
|
unreadCount: { type: Number, default: 0 },
|
||||||
})
|
notice: { type: Boolean, default: false },
|
||||||
|
customBack: { type: Boolean, default: false },
|
||||||
|
});
|
||||||
|
|
||||||
const goBack = () => uni.navigateBack()
|
const emit = defineEmits(["notice", "action", "back"]);
|
||||||
|
|
||||||
|
const handleBack = () => {
|
||||||
|
if (props.customBack) {
|
||||||
|
emit("back");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
return goBack();
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
|
.page-header-slot {
|
||||||
|
height: calc(104rpx + var(--status-bar-height, 0px));
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header-slot--root {
|
||||||
|
height: calc(88rpx + var(--status-bar-height, 0px));
|
||||||
|
}
|
||||||
|
|
||||||
.page-header {
|
.page-header {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
left: 0;
|
||||||
|
z-index: 30;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
height: 104rpx;
|
height: calc(104rpx + var(--status-bar-height, 0px));
|
||||||
padding: 0 24rpx;
|
padding: 0 24rpx;
|
||||||
|
padding-top: var(--status-bar-height, 0px);
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
background: $brand-red;
|
background: $brand-red;
|
||||||
color: #fff9ed;
|
color: #fff9ed;
|
||||||
|
transform: translateZ(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-header--root {
|
.page-header--root {
|
||||||
position: relative;
|
height: calc(88rpx + var(--status-bar-height, 0px));
|
||||||
z-index: 3;
|
padding-top: var(--status-bar-height, 0px);
|
||||||
height: 184rpx;
|
|
||||||
padding-top: env(safe-area-inset-top);
|
|
||||||
background-color: #b52e22;
|
background-color: #b52e22;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
@@ -67,66 +164,181 @@ const goBack = () => uni.navigateBack()
|
|||||||
|
|
||||||
.header-hall {
|
.header-hall {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: -8rpx;
|
bottom: -2rpx;
|
||||||
left: -34rpx;
|
left: -48rpx;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
width: 368rpx;
|
width: 408rpx;
|
||||||
height: 147rpx;
|
height: 126rpx;
|
||||||
opacity: .16;
|
opacity: 0.29;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-header--root .header-side,
|
.page-header--root .header-side,
|
||||||
.page-header--root .header-title { position: relative; z-index: 2; }
|
.page-header--root .header-title {
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
.header-side {
|
.header-side {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex: 0 0 120rpx;
|
||||||
width: 120rpx;
|
width: 120rpx;
|
||||||
min-height: 88rpx;
|
min-height: 88rpx;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-side--left { justify-content: flex-start; }
|
.header-side--left {
|
||||||
.header-side--right { justify-content: flex-end; }
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
.header-icon-button {
|
.header-side--right {
|
||||||
position: relative;
|
justify-content: flex-end;
|
||||||
display: flex;
|
}
|
||||||
width: 88rpx;
|
.page-header:not(.page-header--root) .header-side--right {
|
||||||
height: 88rpx;
|
flex-basis: 152rpx;
|
||||||
align-items: center;
|
width: 152rpx;
|
||||||
justify-content: center;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-logo { width: 66rpx; height: 66rpx; }
|
.header-icon-button {
|
||||||
.header-notice-icon { width: 48rpx; height: 48rpx; }
|
display: grid;
|
||||||
|
width: 88rpx;
|
||||||
|
height: 88rpx;
|
||||||
|
place-items: center;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
line-height: normal;
|
||||||
|
}
|
||||||
|
.header-brand {
|
||||||
|
display: grid;
|
||||||
|
width: 88rpx;
|
||||||
|
min-height: 88rpx;
|
||||||
|
place-items: center;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
line-height: normal;
|
||||||
|
}
|
||||||
|
.header-icon-button::after,
|
||||||
|
.header-back::after,
|
||||||
|
.header-action::after {
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-logo {
|
||||||
|
width: 66rpx;
|
||||||
|
height: 66rpx;
|
||||||
|
}
|
||||||
|
.header-notice-icon {
|
||||||
|
width: 48rpx;
|
||||||
|
height: 48rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-logo,
|
||||||
|
.header-notice-icon,
|
||||||
|
.notice-dot {
|
||||||
|
grid-area: 1 / 1;
|
||||||
|
}
|
||||||
|
|
||||||
.notice-dot {
|
.notice-dot {
|
||||||
position: absolute;
|
align-self: start;
|
||||||
top: 15rpx;
|
justify-self: end;
|
||||||
right: 13rpx;
|
|
||||||
width: 14rpx;
|
width: 14rpx;
|
||||||
height: 14rpx;
|
height: 14rpx;
|
||||||
|
margin-top: 15rpx;
|
||||||
|
margin-right: 13rpx;
|
||||||
border: 2rpx solid $brand-red;
|
border: 2rpx solid $brand-red;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: #fff9ed;
|
background: #fff9ed;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-back,
|
|
||||||
.header-action {
|
.header-action {
|
||||||
|
display: flex;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
font-size: 27rpx;
|
min-height: 88rpx;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: #ffe3a7;
|
||||||
|
font-size: clamp(16px, 27rpx, 20px);
|
||||||
|
line-height: normal;
|
||||||
|
text-align: right;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.header-action-placeholder {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 88rpx;
|
||||||
|
}
|
||||||
|
.header-action--root {
|
||||||
|
color: #ffe3a7;
|
||||||
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-back { text-align: left; }
|
.header-back {
|
||||||
.header-action { color: #ffe3a7; text-align: right; }
|
display: flex;
|
||||||
|
width: 88rpx;
|
||||||
|
height: 88rpx;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-start;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
line-height: normal;
|
||||||
|
}
|
||||||
|
.header-back__icon {
|
||||||
|
width: 42rpx;
|
||||||
|
height: 42rpx;
|
||||||
|
filter: brightness(0) invert(1);
|
||||||
|
transform: scaleX(-1);
|
||||||
|
}
|
||||||
|
.header-back--pressed {
|
||||||
|
opacity: 0.62;
|
||||||
|
}
|
||||||
|
.header-project-lockup {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(var(--status-bar-height, 0px) + 52rpx);
|
||||||
|
left: 112rpx;
|
||||||
|
z-index: 3;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 7rpx;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-project-lockup--root {
|
||||||
|
top: calc(var(--status-bar-height, 0px) + 44rpx);
|
||||||
|
left: 102rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-project-lockup__logo {
|
||||||
|
width: 34rpx;
|
||||||
|
height: 34rpx;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-project-name {
|
||||||
|
color: #ffe3a7;
|
||||||
|
font-family: "STKaiti", "KaiTi", serif;
|
||||||
|
font-size: clamp(11px, 19rpx, 14px);
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 3rpx;
|
||||||
|
line-height: 1;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
.header-title {
|
.header-title {
|
||||||
|
min-width: 0;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
color: #fff9ed;
|
color: #fff9ed;
|
||||||
font-family: serif;
|
font-family: serif;
|
||||||
font-size: 35rpx;
|
font-size: clamp(19px, 35rpx, 24px);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
letter-spacing: 4rpx;
|
letter-spacing: 4rpx;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
@@ -136,17 +348,18 @@ const goBack = () => uni.navigateBack()
|
|||||||
|
|
||||||
.page-header--root .header-title {
|
.page-header--root .header-title {
|
||||||
color: #ffe3a7;
|
color: #ffe3a7;
|
||||||
font-family: 'STKaiti', 'KaiTi', serif;
|
font-family: "STKaiti", "KaiTi", serif;
|
||||||
font-size: 48rpx;
|
font-size: clamp(21px, 40rpx, 25px);
|
||||||
letter-spacing: 5rpx;
|
letter-spacing: 4rpx;
|
||||||
transform: translateY(8rpx);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-header--root .header-logo {
|
.page-header--root .header-logo {
|
||||||
width: 88rpx;
|
width: 64rpx;
|
||||||
height: 88rpx;
|
height: 64rpx;
|
||||||
transform: translate(-8rpx, -12rpx);
|
transform: translate(-2rpx, -2rpx);
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-header--root .header-notice { transform: translateY(10rpx); }
|
.page-header--root .header-notice {
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
</style>
|
</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>
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
<template>
|
||||||
|
<view
|
||||||
|
class="genealogy-page-background"
|
||||||
|
:class="{ 'genealogy-page-background--light': tone === 'light' }"
|
||||||
|
>
|
||||||
|
<image
|
||||||
|
class="genealogy-page-background__art"
|
||||||
|
src="/static/assets/modules/genealogy/opaque/genealogy-page-background-long.png"
|
||||||
|
mode="aspectFill"
|
||||||
|
/>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
defineProps({
|
||||||
|
tone: { type: String, default: "default" },
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.genealogy-page-background {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #e7ded1;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.genealogy-page-background__art {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
right: 0;
|
||||||
|
left: 0;
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
opacity: 0.28;
|
||||||
|
}
|
||||||
|
|
||||||
|
.genealogy-page-background--light {
|
||||||
|
background: #faf8f3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.genealogy-page-background--light .genealogy-page-background__art {
|
||||||
|
opacity: 0.15;
|
||||||
|
}
|
||||||
|
|
||||||
|
</style>
|
||||||
@@ -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>
|
||||||
@@ -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,68 +0,0 @@
|
|||||||
export const currentUser = {
|
|
||||||
id: 1,
|
|
||||||
name: '汤文远',
|
|
||||||
phone: '138****1024',
|
|
||||||
role: '创建者'
|
|
||||||
}
|
|
||||||
|
|
||||||
export const genealogies = [
|
|
||||||
{
|
|
||||||
id: 1001,
|
|
||||||
surname: '汤',
|
|
||||||
name: '汤氏家谱',
|
|
||||||
hall: '敦睦堂',
|
|
||||||
location: '河南·洛阳',
|
|
||||||
memberCount: 158,
|
|
||||||
updatedAt: '2024-05-12',
|
|
||||||
activeCount: 8,
|
|
||||||
visibility: '仅成员可见',
|
|
||||||
motto: '敦亲睦族,敬祖传家。'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 1002,
|
|
||||||
surname: '汤',
|
|
||||||
name: '汤氏宗谱',
|
|
||||||
hall: '承志堂',
|
|
||||||
location: '山东·济宁',
|
|
||||||
memberCount: 286,
|
|
||||||
updatedAt: '2024-04-28',
|
|
||||||
activeCount: 15,
|
|
||||||
visibility: '公开可申请',
|
|
||||||
motto: '继往开来,世守家风。'
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
export const treeMembers = [
|
|
||||||
{ id: 1, name: '汤文远', relation: '始祖', generation: 1, years: '1940—2012', branch: '主支', x: 50, y: 7 },
|
|
||||||
{ id: 2, name: '汤正国', relation: '长子', generation: 2, years: '1965—', branch: '主支', x: 20, y: 38 },
|
|
||||||
{ id: 3, name: '汤正华', relation: '次子', generation: 2, years: '1968—', branch: '主支', x: 50, y: 38 },
|
|
||||||
{ id: 4, name: '汤正强', relation: '三子', generation: 2, years: '1972—', branch: '支系', x: 80, y: 38 },
|
|
||||||
{ id: 5, name: '汤凯', relation: '长孙', generation: 3, years: '1992—', branch: '主支', x: 16, y: 70 },
|
|
||||||
{ id: 6, name: '汤悦', relation: '长女', generation: 3, years: '1995—', branch: '主支', x: 35, y: 70 },
|
|
||||||
{ id: 7, name: '汤晨', relation: '次子', generation: 3, years: '1998—', branch: '主支', x: 54, y: 70 },
|
|
||||||
{ id: 8, name: '汤昊', relation: '三子', generation: 3, years: '2001—', branch: '支系', x: 73, y: 70 },
|
|
||||||
{ id: 9, name: '汤宁', relation: '四女', generation: 3, years: '2005—', branch: '支系', x: 90, y: 70 }
|
|
||||||
]
|
|
||||||
|
|
||||||
export const familyFeeds = [
|
|
||||||
{ id: 1, author: '汤正华', title: '清明祭祖通知', content: '本周六上午举行清明祭祖,敬请各支系族亲相互转告。', date: '今天 09:30', type: '族务' },
|
|
||||||
{ id: 2, author: '汤悦', title: '老宅修缮旧影', content: '整理出一组敦睦堂老宅照片,已收入家族相册。', date: '昨天 20:18', type: '相册' }
|
|
||||||
]
|
|
||||||
|
|
||||||
export const familyContent = {
|
|
||||||
article: [{ id: 1, title: '敦睦堂家训摘录', summary: '孝友传家,勤俭立业,敬祖睦族。', date: '2025年4月' }],
|
|
||||||
album: [{ id: 1, title: '敦睦堂老宅旧影', summary: '收录修缮前后的珍贵照片,共 6 张。', date: '2025年3月' }],
|
|
||||||
ceremony: [{ id: 1, title: '清明祭祖', summary: '本周六上午举行祭祖仪式,请族人相互转告。', date: '3 天后' }],
|
|
||||||
record: [{ id: 1, title: '老宅修缮备忘', summary: '屋脊木构加固方案已确认,等待施工。', date: '今天' }]
|
|
||||||
}
|
|
||||||
|
|
||||||
export const notifications = [
|
|
||||||
{ id: 1, title: '有新的入谱申请', content: '陈先生申请加入四川武胜汤氏族谱,等待你的审核。', time: '10:24', unread: true },
|
|
||||||
{ id: 2, title: '祭祀活动提醒', content: '清明祭祖将在 3 天后开始。', time: '昨天', unread: true },
|
|
||||||
{ id: 3, title: '相册有新照片', content: '汤悦上传了 6 张老宅修缮照片。', time: '4月9日', unread: false }
|
|
||||||
]
|
|
||||||
|
|
||||||
export const joinApplications = [
|
|
||||||
{ id: 1, name: '汤志成', phone: '139****6421', relation: '自述为汤正华堂侄', appliedAt: '今天 10:24', status: 'PENDING' },
|
|
||||||
{ id: 2, name: '汤雨薇', phone: '136****2798', relation: '自述为汤正国之女', appliedAt: '昨天 18:02', status: 'PENDING' }
|
|
||||||
]
|
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
{
|
||||||
|
"schemaVersion": 1,
|
||||||
|
"kind": "runtime-asset-inventory",
|
||||||
|
"scope": "application-direct",
|
||||||
|
"imports": [],
|
||||||
|
"assets": [
|
||||||
|
{ "id": "app-foundation-opaque-root-header-cinnabar", "output": "static/assets/foundation/opaque/root-header-cinnabar.jpg", "width": 720, "height": 184, "alpha": false, "bytes": 9356, "sha256": "9561b1e4d11d3cdd005b902c6d989aadc48bd614bec5ddbd65a32699983b4b37", "provenance": "committed-binary", "rebuildable": false },
|
||||||
|
{ "id": "app-foundation-transparent-auth-title-cloud", "output": "static/assets/foundation/transparent/auth-title-cloud.png", "width": 200, "height": 120, "alpha": true, "bytes": 15833, "sha256": "c6dc6e367198e815b28b8f88c4012b850f2a21195d8ca4ecaf27c0204eba64f2", "provenance": "committed-binary", "rebuildable": false },
|
||||||
|
{ "id": "app-foundation-transparent-meta-admin", "output": "static/assets/foundation/transparent/meta-admin.png", "width": 96, "height": 96, "alpha": true, "bytes": 4705, "sha256": "d2363e3a4f990dcf5925f0b67ac51b8d3d0fe79d4e7d753ddd8df5b412a15517", "provenance": "committed-binary", "rebuildable": false },
|
||||||
|
{ "id": "app-foundation-transparent-meta-location", "output": "static/assets/foundation/transparent/meta-location.png", "width": 96, "height": 96, "alpha": true, "bytes": 3634, "sha256": "ed1285d91df018a6e7628cca944428e1582fb10ddeaf75ba7743481340adc12c", "provenance": "committed-binary", "rebuildable": false },
|
||||||
|
{ "id": "app-foundation-transparent-meta-member", "output": "static/assets/foundation/transparent/meta-member.png", "width": 96, "height": 96, "alpha": true, "bytes": 8931, "sha256": "c89824e8e19210dd746f56019a48c9a833abe4a210b222758a56c3d25dec9d0c", "provenance": "committed-binary", "rebuildable": false },
|
||||||
|
{ "id": "app-foundation-transparent-notice", "output": "static/assets/foundation/transparent/notice.png", "width": 96, "height": 96, "alpha": true, "bytes": 8775, "sha256": "a75ce2368afa661edb4b394d0a58bc514248fa994f9e732d1d6833caa79353e1", "provenance": "committed-binary", "rebuildable": false },
|
||||||
|
{ "id": "app-foundation-transparent-root-header-hall", "output": "static/assets/foundation/transparent/root-header-hall.png", "width": 750, "height": 300, "alpha": true, "bytes": 159144, "sha256": "a4b55282fb42f0200636e7d5a66cd1f237bea01521c312fa99a3b7278cf8ff49", "provenance": "committed-binary", "rebuildable": false },
|
||||||
|
{ "id": "app-foundation-transparent-tab-family-active", "output": "static/assets/foundation/transparent/tab-family-active.png", "width": 96, "height": 96, "alpha": true, "bytes": 2620, "sha256": "007f03d04927fbf6638fff027335f45db1331adb36979bcbca0d577bb28046f5", "provenance": "committed-binary", "rebuildable": false },
|
||||||
|
{ "id": "app-foundation-transparent-tab-family", "output": "static/assets/foundation/transparent/tab-family.png", "width": 96, "height": 96, "alpha": true, "bytes": 2621, "sha256": "c7e0083e4ca045b084dab6e77cbece8a26f490dec628e386689d68a57d03387a", "provenance": "committed-binary", "rebuildable": false },
|
||||||
|
{ "id": "app-foundation-transparent-tab-genealogy-active", "output": "static/assets/foundation/transparent/tab-genealogy-active.png", "width": 96, "height": 96, "alpha": true, "bytes": 3142, "sha256": "99ad57c8868eb618f62601882f7748edd5b87c81cf1d0fe5bf2324113930a88c", "provenance": "committed-binary", "rebuildable": false },
|
||||||
|
{ "id": "app-foundation-transparent-tab-genealogy", "output": "static/assets/foundation/transparent/tab-genealogy.png", "width": 96, "height": 96, "alpha": true, "bytes": 3141, "sha256": "88d5653db7521b846ef81736355e9ccbbb59daf6dfe92c9c2748bef6456af646", "provenance": "committed-binary", "rebuildable": false },
|
||||||
|
{ "id": "app-foundation-transparent-tab-profile-active", "output": "static/assets/foundation/transparent/tab-profile-active.png", "width": 96, "height": 96, "alpha": true, "bytes": 1973, "sha256": "5ff15db3b4cc64934e4ae9602e604345501c1f86160da10d30d9c0a80ef95d36", "provenance": "committed-binary", "rebuildable": false },
|
||||||
|
{ "id": "app-foundation-transparent-tab-profile", "output": "static/assets/foundation/transparent/tab-profile.png", "width": 96, "height": 96, "alpha": true, "bytes": 1973, "sha256": "45fc22b367807bdc465e6e17230a10276a4d7e453e27b7cbc064af7fdc255470", "provenance": "committed-binary", "rebuildable": false },
|
||||||
|
{ "id": "app-modules-family-transparent-family-letter-card", "output": "static/assets/modules/family/transparent/family-letter-card.png", "width": 2003, "height": 581, "alpha": true, "bytes": 1513491, "sha256": "a3686f99e32cdc255c0fc130294aab1974392d66bc8a7991daeb8f67849015ee", "provenance": "committed-binary", "rebuildable": false },
|
||||||
|
{ "id": "app-modules-family-transparent-module-content-frame", "output": "static/assets/modules/family/transparent/module-content-frame.png", "width": 2003, "height": 581, "alpha": true, "bytes": 1513491, "sha256": "a3686f99e32cdc255c0fc130294aab1974392d66bc8a7991daeb8f67849015ee", "provenance": "committed-binary", "rebuildable": false },
|
||||||
|
{ "id": "app-modules-family-transparent-module-field-frame", "output": "static/assets/modules/family/transparent/module-field-frame.png", "width": 2132, "height": 443, "alpha": true, "bytes": 1065770, "sha256": "bdc65f33e1dbe05ae75562519b0080e2e5cdbe2dba720a6ac49b56b99be75ceb", "provenance": "committed-binary", "rebuildable": false },
|
||||||
|
{ "id": "app-modules-genealogy-opaque-home-ad-family-tree", "output": "static/assets/modules/genealogy/opaque/home-ad-family-tree.png", "width": 1862, "height": 845, "alpha": false, "bytes": 2701295, "sha256": "d333924d07569491809211508302c064bba6eee52b4af489a526bf53ba066be4", "provenance": "committed-binary", "rebuildable": false },
|
||||||
|
{ "id": "app-modules-genealogy-opaque-home-ad-heritage-hall", "output": "static/assets/modules/genealogy/opaque/home-ad-heritage-hall.png", "width": 1862, "height": 845, "alpha": false, "bytes": 2554759, "sha256": "c64fbb4fc965a084a7b1184c924673f6df25a3504858df3a790973664d886b6e", "provenance": "committed-binary", "rebuildable": false },
|
||||||
|
{ "id": "app-modules-genealogy-opaque-overview-surface", "output": "static/assets/modules/genealogy/opaque/overview-surface.png", "width": 1122, "height": 1506, "alpha": false, "bytes": 2537499, "sha256": "2d908f53c7edb637c7ced7500822bd33d354410ad8e671871d1da3a4cbd0f4db", "provenance": "committed-binary", "rebuildable": false },
|
||||||
|
{ "id": "app-modules-genealogy-opaque-search-button", "output": "static/assets/modules/genealogy/opaque/search-button.png", "width": 300, "height": 132, "alpha": false, "bytes": 74664, "sha256": "bf0abc0a036c07322dd834fe5a9797413afd453b69d2ff8a895f3999fb8846cd", "provenance": "committed-binary", "rebuildable": false },
|
||||||
|
{ "id": "app-modules-genealogy-opaque-search-input-wide", "output": "static/assets/modules/genealogy/opaque/search-input-wide.png", "width": 1120, "height": 248, "alpha": false, "bytes": 319364, "sha256": "2a575eeb5582c8458cbdd04cd328afbbecbdaabf4db46e8f2133bfe2614faa80", "provenance": "committed-binary", "rebuildable": false },
|
||||||
|
{ "id": "app-modules-genealogy-transparent-add", "output": "static/assets/modules/genealogy/transparent/add.png", "width": 96, "height": 96, "alpha": true, "bytes": 5226, "sha256": "671846ee23f8df701e2e1dfc34e3520dfe9f719f106efce56e30d78b84a47a0b", "provenance": "committed-binary", "rebuildable": false },
|
||||||
|
{ "id": "app-modules-genealogy-transparent-create-cloud", "output": "static/assets/modules/genealogy/transparent/create-cloud.png", "width": 192, "height": 96, "alpha": true, "bytes": 10538, "sha256": "be01c1322181d91264641879162f6371d21ffda1feb971f5f31a791f32d02206", "provenance": "committed-binary", "rebuildable": false },
|
||||||
|
{ "id": "app-modules-genealogy-transparent-current-seal-frame", "output": "static/assets/modules/genealogy/transparent/current-seal-frame.png", "width": 144, "height": 208, "alpha": true, "bytes": 1214, "sha256": "6d9aa61d766eec077cd91fbd5b71079990e67bcdc213d631a1185714dfb09328", "provenance": "committed-binary", "rebuildable": false },
|
||||||
|
{ "id": "app-modules-genealogy-transparent-current-slip-frame", "output": "static/assets/modules/genealogy/transparent/current-slip-frame.png", "width": 720, "height": 272, "alpha": true, "bytes": 18568, "sha256": "47e519768830062a2363bd4bd10fb9ffc82c6a94638d3400a1e4c943a0ca6e66", "provenance": "committed-binary", "rebuildable": false },
|
||||||
|
{ "id": "app-modules-genealogy-transparent-form-field-frame", "output": "static/assets/modules/genealogy/transparent/form-field-frame.png", "width": 2132, "height": 443, "alpha": true, "bytes": 1065770, "sha256": "bdc65f33e1dbe05ae75562519b0080e2e5cdbe2dba720a6ac49b56b99be75ceb", "provenance": "committed-binary", "rebuildable": false },
|
||||||
|
{ "id": "app-modules-genealogy-transparent-add-sheet-background", "output": "static/assets/modules/genealogy/transparent/add-sheet-background.png", "width": 1536, "height": 1024, "alpha": true, "bytes": 1423794, "sha256": "181b9d9c95f0e8cce284dc6640adb4fa6c4fa9691306052c9e33558abf930e85", "provenance": "committed-binary", "rebuildable": false },
|
||||||
|
{ "id": "app-modules-genealogy-transparent-dialog-close", "output": "static/assets/modules/genealogy/transparent/dialog-close.png", "width": 1254, "height": 1254, "alpha": true, "bytes": 105163, "sha256": "909fe4badccbfad1ddcfe4a21292ba73fecde32efb914f1c7fcb717f32f9db3f", "provenance": "committed-binary", "rebuildable": false },
|
||||||
|
{ "id": "app-modules-genealogy-transparent-list-slip-frame", "output": "static/assets/modules/genealogy/transparent/list-slip-frame.png", "width": 720, "height": 144, "alpha": true, "bytes": 2992, "sha256": "1e39ed72c000e569340049833b1afca9e6aaeb077618a91e0c0f4f2758113005", "provenance": "committed-binary", "rebuildable": false },
|
||||||
|
{ "id": "app-modules-genealogy-transparent-row-seal-frame", "output": "static/assets/modules/genealogy/transparent/row-seal-frame.png", "width": 112, "height": 160, "alpha": true, "bytes": 875, "sha256": "f2f2b0260fe54d738a70168f28d3960622d4b3e57c4ef95a032716008de6626b", "provenance": "committed-binary", "rebuildable": false },
|
||||||
|
{ "id": "app-modules-genealogy-transparent-section-divider", "output": "static/assets/modules/genealogy/transparent/section-divider.png", "width": 720, "height": 30, "alpha": true, "bytes": 6645, "sha256": "925241a6cd2bd9897edad60e53eb255033603bef1028afbc189e87a29fddc6cd", "provenance": "committed-binary", "rebuildable": false },
|
||||||
|
{ "id": "app-modules-genealogy-transparent-shortcut-application", "output": "static/assets/modules/genealogy/transparent/shortcut-application.png", "width": 96, "height": 96, "alpha": true, "bytes": 8925, "sha256": "3842aec0357ee1013ddc230fe1030894ad5eb74101aa696bd8f986ad0c1bdd93", "provenance": "committed-binary", "rebuildable": false },
|
||||||
|
{ "id": "app-modules-genealogy-transparent-shortcut-generation-poem", "output": "static/assets/modules/genealogy/transparent/shortcut-generation-poem.png", "width": 96, "height": 96, "alpha": true, "bytes": 8169, "sha256": "688e593fa88925ea00acf37570852a2dca9e1a505ba3b8107e0ddd3d146a6f4e", "provenance": "committed-binary", "rebuildable": false },
|
||||||
|
{ "id": "app-modules-genealogy-transparent-shortcut-members", "output": "static/assets/modules/genealogy/transparent/shortcut-members.png", "width": 96, "height": 96, "alpha": true, "bytes": 8931, "sha256": "c89824e8e19210dd746f56019a48c9a833abe4a210b222758a56c3d25dec9d0c", "provenance": "committed-binary", "rebuildable": false },
|
||||||
|
{ "id": "app-modules-genealogy-transparent-shortcut-tree", "output": "static/assets/modules/genealogy/transparent/shortcut-tree.png", "width": 96, "height": 96, "alpha": true, "bytes": 6636, "sha256": "cafe12a3fa800ca79d6c559baa533d7e26124ec8ac0072482ddb89ac2167422d", "provenance": "committed-binary", "rebuildable": false },
|
||||||
|
{ "id": "app-modules-notification-transparent-notice-card", "output": "static/assets/modules/notification/transparent/notice-card.png", "width": 2139, "height": 675, "alpha": true, "bytes": 254885, "sha256": "01cf572ed3f20d99e10a21fee6834dced385e71d6b14693b04c0cb49560a6fd3", "provenance": "committed-binary", "rebuildable": false },
|
||||||
|
{ "id": "app-modules-profile-transparent-summary-card", "output": "static/assets/modules/profile/transparent/summary-card.png", "width": 2139, "height": 675, "alpha": true, "bytes": 254885, "sha256": "01cf572ed3f20d99e10a21fee6834dced385e71d6b14693b04c0cb49560a6fd3", "provenance": "committed-binary", "rebuildable": false },
|
||||||
|
{ "id": "app-modules-profile-transparent-module-content-frame", "output": "static/assets/modules/profile/transparent/module-content-frame.png", "width": 2139, "height": 675, "alpha": true, "bytes": 254885, "sha256": "01cf572ed3f20d99e10a21fee6834dced385e71d6b14693b04c0cb49560a6fd3", "provenance": "committed-binary", "rebuildable": false },
|
||||||
|
{ "id": "app-modules-profile-transparent-module-field-frame", "output": "static/assets/modules/profile/transparent/module-field-frame.png", "width": 2132, "height": 443, "alpha": true, "bytes": 1065770, "sha256": "bdc65f33e1dbe05ae75562519b0080e2e5cdbe2dba720a6ac49b56b99be75ceb", "provenance": "committed-binary", "rebuildable": false },
|
||||||
|
{ "id": "app-modules-records-transparent-module-content-frame", "output": "static/assets/modules/records/transparent/module-content-frame.png", "width": 2139, "height": 675, "alpha": true, "bytes": 254885, "sha256": "01cf572ed3f20d99e10a21fee6834dced385e71d6b14693b04c0cb49560a6fd3", "provenance": "committed-binary", "rebuildable": false },
|
||||||
|
{ "id": "app-modules-records-transparent-module-field-frame", "output": "static/assets/modules/records/transparent/module-field-frame.png", "width": 2132, "height": 443, "alpha": true, "bytes": 1065770, "sha256": "bdc65f33e1dbe05ae75562519b0080e2e5cdbe2dba720a6ac49b56b99be75ceb", "provenance": "committed-binary", "rebuildable": false },
|
||||||
|
{ "id": "app-modules-records-transparent-person-name-card", "output": "static/assets/modules/records/transparent/person-name-card.png", "width": 2139, "height": 675, "alpha": true, "bytes": 254885, "sha256": "01cf572ed3f20d99e10a21fee6834dced385e71d6b14693b04c0cb49560a6fd3", "provenance": "committed-binary", "rebuildable": false },
|
||||||
|
{ "id": "app-modules-tree-transparent-member-node-selected", "output": "static/assets/modules/tree/transparent/member-node-selected.png", "width": 720, "height": 272, "alpha": true, "bytes": 18568, "sha256": "47e519768830062a2363bd4bd10fb9ffc82c6a94638d3400a1e4c943a0ca6e66", "provenance": "committed-binary", "rebuildable": false },
|
||||||
|
{ "id": "app-modules-tree-transparent-member-node-standard", "output": "static/assets/modules/tree/transparent/member-node-standard.png", "width": 720, "height": 272, "alpha": true, "bytes": 18568, "sha256": "47e519768830062a2363bd4bd10fb9ffc82c6a94638d3400a1e4c943a0ca6e66", "provenance": "committed-binary", "rebuildable": false },
|
||||||
|
{ "id": "app-modules-tree-transparent-member-node-portrait", "output": "static/assets/modules/tree/transparent/member-node-portrait.png", "width": 81, "height": 105, "alpha": true, "bytes": 1141, "sha256": "e0e1609ae3f70ef9ab0fec82f2ef9bdb8af1ad7966a2cfefa3aae41d9684212f", "provenance": "committed-binary", "rebuildable": false },
|
||||||
|
{ "id": "app-modules-tree-transparent-state-panel", "output": "static/assets/modules/tree/transparent/state-panel.png", "width": 2139, "height": 675, "alpha": true, "bytes": 254885, "sha256": "01cf572ed3f20d99e10a21fee6834dced385e71d6b14693b04c0cb49560a6fd3", "provenance": "committed-binary", "rebuildable": false },
|
||||||
|
{ "id": "app-modules-tree-transparent-search-input-frame", "output": "static/assets/modules/tree/transparent/search-input-frame.png", "width": 2132, "height": 443, "alpha": true, "bytes": 1065770, "sha256": "bdc65f33e1dbe05ae75562519b0080e2e5cdbe2dba720a6ac49b56b99be75ceb", "provenance": "committed-binary", "rebuildable": false },
|
||||||
|
{ "id": "app-foundation-transparent-book-png", "output": "static/assets/foundation/transparent/book.png", "width": 154, "height": 210, "alpha": false, "bytes": 41283, "sha256": "d565a2ad4f90c07046e9c60ebd6658426cacdd1f2af79e0a28ce9fe59d670630", "provenance": "committed-binary", "rebuildable": false },
|
||||||
|
{ "id": "app-foundation-opaque-book-blank", "output": "static/assets/foundation/opaque/book-blank.png", "width": 154, "height": 210, "alpha": false, "bytes": 46607, "sha256": "500441f7631b45e1f4367a0b4f7f27031d1585097a458af599e8c7b922314454", "provenance": "committed-binary", "rebuildable": false },
|
||||||
|
{ "id": "app-foundation-transparent-default-avatar-female", "output": "static/assets/foundation/transparent/default-avatar-female.png", "width": 128, "height": 128, "alpha": true, "bytes": 38629, "sha256": "a85cf941e531e84217f37b42fe8e8aa9ab6a32457480d45aa3d6fa33da360d2c", "provenance": "committed-binary", "rebuildable": false },
|
||||||
|
{ "id": "app-foundation-transparent-default-avatar-male", "output": "static/assets/foundation/transparent/default-avatar-male.png", "width": 128, "height": 128, "alpha": true, "bytes": 43055, "sha256": "b00cb59f440aa87215432afc64f1463a61f0d9806ba1655ed6f8888a9d04c100", "provenance": "committed-binary", "rebuildable": false },
|
||||||
|
{ "id": "app-modules-genealogy-transparent-settings-gear", "output": "static/assets/modules/genealogy/transparent/settings-gear.png", "width": 1254, "height": 1254, "alpha": true, "bytes": 1305800, "sha256": "0c3f064878a865af57ea3e7de450703a07bbb59b81ef0c44c4a041fa35a5dd1c", "provenance": "committed-binary", "rebuildable": false },
|
||||||
|
{ "id": "app-modules-profile-opaque-archive-hero", "output": "static/assets/modules/profile/opaque/archive-hero.png", "width": 2172, "height": 724, "alpha": false, "bytes": 1965431, "sha256": "eda978404614f2ae14e200d8cf7bb05851cc7bccf15b515655b9387dc39cb829", "provenance": "committed-binary", "rebuildable": false },
|
||||||
|
{ "id": "app-modules-profile-opaque-paper-mountains", "output": "static/assets/modules/profile/opaque/paper-mountains.png", "width": 941, "height": 1672, "alpha": false, "bytes": 1862070, "sha256": "60b54ef2550a2709e9ccbe7eab1cb4fa22c08ebace4be0930f746df1a1d607b2", "provenance": "committed-binary", "rebuildable": false },
|
||||||
|
{ "id": "app-modules-profile-opaque-featured-courtyard", "output": "static/assets/modules/profile/opaque/featured-courtyard.png", "width": 1536, "height": 1024, "alpha": false, "bytes": 2577643, "sha256": "3431f3e84f1521bb73a21407d48e127d9f7d20d99805b11c38b8383f660808c7", "provenance": "committed-binary", "rebuildable": false },
|
||||||
|
{ "id": "app-modules-profile-opaque-vip-hero-landscape", "output": "static/assets/modules/profile/opaque/vip-hero-landscape.png", "width": 2172, "height": 724, "alpha": false, "bytes": 2194380, "sha256": "a286f7b685a5b9f91cdeaf99f3454e5b10c0712d7a062fca5f39f0c4a9028491", "provenance": "committed-binary", "rebuildable": false },
|
||||||
|
{ "id": "app-modules-profile-transparent-icon-calendar", "output": "static/assets/modules/profile/transparent/icon-calendar.png", "width": 256, "height": 256, "alpha": true, "bytes": 78155, "sha256": "bec3a1bce56c0bb31cac4a474f2bde9c8d904fbbc22c017e09d733364b014ed8", "provenance": "committed-binary", "rebuildable": false },
|
||||||
|
{ "id": "app-modules-profile-transparent-icon-envelope", "output": "static/assets/modules/profile/transparent/icon-envelope.png", "width": 256, "height": 256, "alpha": true, "bytes": 73975, "sha256": "2462b34ef6b48d24be4cd2ce92c7931a057a254a666d7351f77a79263d61c486", "provenance": "committed-binary", "rebuildable": false },
|
||||||
|
{ "id": "app-modules-profile-transparent-icon-feedback", "output": "static/assets/modules/profile/transparent/icon-feedback.png", "width": 256, "height": 256, "alpha": true, "bytes": 80060, "sha256": "3d194d983a2b6bea1a398d0055d68b643c2d4d91ecb3857ea967944b0900b352", "provenance": "committed-binary", "rebuildable": false },
|
||||||
|
{ "id": "app-modules-profile-transparent-icon-help", "output": "static/assets/modules/profile/transparent/icon-help.png", "width": 256, "height": 256, "alpha": true, "bytes": 79859, "sha256": "a63f9d9756b152d3eed5e2bf5ecd1797204260511bb575b5d20082db2d735cd8", "provenance": "committed-binary", "rebuildable": false },
|
||||||
|
{ "id": "app-modules-profile-transparent-icon-message", "output": "static/assets/modules/profile/transparent/icon-message.png", "width": 256, "height": 256, "alpha": true, "bytes": 75063, "sha256": "71a290aa4f76b4d786ab97e9808994636eb5f6ef252b3feaf6ac9c7e3d4c90f9", "provenance": "committed-binary", "rebuildable": false },
|
||||||
|
{ "id": "app-modules-profile-transparent-icon-person", "output": "static/assets/modules/profile/transparent/icon-person.png", "width": 256, "height": 256, "alpha": true, "bytes": 76220, "sha256": "e2cb0d86614a1ae4c8da7b56426c1399d47c6b6c198d22fb11e9e21e0f4ab32d", "provenance": "committed-binary", "rebuildable": false },
|
||||||
|
{ "id": "app-modules-profile-transparent-icon-promotion", "output": "static/assets/modules/profile/transparent/icon-promotion.png", "width": 256, "height": 256, "alpha": true, "bytes": 77714, "sha256": "81fa5248ce378a0f4b33dc5396a8b1b753c74f1c55feb19c874b43d632c2ae41", "provenance": "committed-binary", "rebuildable": false },
|
||||||
|
{ "id": "app-modules-profile-transparent-icon-security", "output": "static/assets/modules/profile/transparent/icon-security.png", "width": 256, "height": 256, "alpha": true, "bytes": 83638, "sha256": "fe7bc7cbda827cba6d2e53add1e601f3296dcf0c7a42973878ea4c23a7a8ad74", "provenance": "committed-binary", "rebuildable": false },
|
||||||
|
{ "id": "app-modules-profile-transparent-icon-settings", "output": "static/assets/modules/profile/transparent/icon-settings.png", "width": 256, "height": 256, "alpha": true, "bytes": 84885, "sha256": "f10c83e75135b2cc80c97963be5b9a8552fbe767c0a518f0106ede41cf441ccb", "provenance": "committed-binary", "rebuildable": false },
|
||||||
|
{ "id": "app-modules-profile-transparent-icon-vip", "output": "static/assets/modules/profile/transparent/icon-vip.png", "width": 256, "height": 256, "alpha": true, "bytes": 80349, "sha256": "66b08710a87d174cb38d24d6db75883a4819a074b848bbb5e7232bf9389e68ab", "provenance": "committed-binary", "rebuildable": false },
|
||||||
|
{ "id": "app-modules-profile-transparent-edit-button", "output": "static/assets/modules/profile/transparent/edit-button.png", "width": 1713, "height": 395, "alpha": true, "bytes": 1215863, "sha256": "58d9d8b2d57e2f7922f608122d1cdb5075e6a9e3b171ba2ce4100817b87d37e8", "provenance": "committed-binary", "rebuildable": false },
|
||||||
|
{ "id": "app-modules-profile-transparent-tree-medallion", "output": "static/assets/modules/profile/transparent/tree-medallion.png", "width": 1254, "height": 1254, "alpha": true, "bytes": 2998737, "sha256": "831ffb68e4d2f0a0c44a9ddabd299e0209fc89fd007c5a1c1d4b709c6859b83d", "provenance": "committed-binary", "rebuildable": false },
|
||||||
|
{ "id": "app-modules-tree-transparent-action-arrow", "output": "static/assets/modules/tree/transparent/action-arrow.png", "width": 96, "height": 96, "alpha": true, "bytes": 2353, "sha256": "d7d80ce66b959ff29be31fb21e9b53f32289339bcdc98c13b883693754eedaf9", "provenance": "committed-binary", "rebuildable": false },
|
||||||
|
{ "id": "app-modules-tree-transparent-action-close", "output": "static/assets/modules/tree/transparent/action-close.png", "width": 128, "height": 128, "alpha": true, "bytes": 5660, "sha256": "c72e937ef06bf89a42b7958f48f3b38eecea66c16013c703ca4bc76c1b7a02e7", "provenance": "committed-binary", "rebuildable": false },
|
||||||
|
{ "id": "app-modules-tree-transparent-action-panel-paper", "output": "static/assets/modules/tree/transparent/action-panel-paper.png", "width": 1034, "height": 1520, "alpha": true, "bytes": 2456802, "sha256": "85d219e828ecbe8042f47fc39587f05134d1bed3300a0ccc201426075849c4ae", "provenance": "committed-binary", "rebuildable": false },
|
||||||
|
{ "id": "app-modules-tree-transparent-management-marker", "output": "static/assets/modules/tree/transparent/management-marker.png", "width": 128, "height": 128, "alpha": true, "bytes": 14014, "sha256": "389c4bb4964c0a7f8ef37cd650f6eda8ed50952eb0cce5f86d9643a30bf93e31", "provenance": "committed-binary", "rebuildable": false },
|
||||||
|
{ "id": "app-modules-tree-transparent-member-medallion", "output": "static/assets/modules/tree/transparent/member-medallion.png", "width": 192, "height": 192, "alpha": true, "bytes": 37801, "sha256": "9f3765f9ce055b95a84fd0c6f7e4844793caf1ac27c7ac2ac628beccbd5883ec", "provenance": "committed-binary", "rebuildable": false },
|
||||||
|
{ "id": "app-modules-tree-transparent-relation-button-frame", "output": "static/assets/modules/tree/transparent/relation-button-frame.png", "width": 1591, "height": 406, "alpha": true, "bytes": 1148107, "sha256": "bffdc12e8d0179fc3c3f38eeb7d5529c1274266205d3f78258887e961f1f8fbb", "provenance": "committed-binary", "rebuildable": false },
|
||||||
|
{ "id": "app-modules-tree-transparent-relation-map", "output": "static/assets/modules/tree/transparent/relation-map.png", "width": 1536, "height": 1024, "alpha": true, "bytes": 156562, "sha256": "550f20ea224ca90be6e5a4ee5ff4bf37716bf4d7f0c77f699e788e3a2c07f8f3", "provenance": "committed-binary", "rebuildable": false },
|
||||||
|
{ "id": "app-modules-tree-transparent-relation-marker", "output": "static/assets/modules/tree/transparent/relation-marker.png", "width": 128, "height": 128, "alpha": true, "bytes": 9268, "sha256": "a5afafa89435f5df5205a6e0537d9982e713fe97b7deb6e80754c71e2a86db31", "provenance": "committed-binary", "rebuildable": false }
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"schemaVersion": 1,
|
||||||
|
"kind": "runtime-asset-inventory",
|
||||||
|
"scope": "auth",
|
||||||
|
"imports": [],
|
||||||
|
"assets": [
|
||||||
|
{ "id": "auth-page-paper", "output": "static/assets/foundation/opaque/auth-page-paper.jpg", "width": 750, "height": 1334, "alpha": false, "bytes": 60355, "sha256": "3eea1eefa815c306f0f4a95ce79878ca53f8f64a7c9b44144c99ca683a38e7b8", "provenance": "committed-binary", "rebuildable": false },
|
||||||
|
{ "id": "auth-divider-knot", "output": "static/assets/foundation/transparent/auth-divider-knot.png", "width": 160, "height": 96, "alpha": true, "bytes": 6751, "sha256": "d45dc052c8eb2ba214c7d4d5846f5b53f01686e00e671bfe0582ed7d9d4a2419", "provenance": "committed-binary", "rebuildable": false },
|
||||||
|
{ "id": "auth-wechat", "output": "static/assets/foundation/transparent/auth-wechat.png", "width": 96, "height": 96, "alpha": true, "bytes": 2215, "sha256": "5c69addac53afcd34064e48fffc19ef56a5d105c92f6130cd6045dd1591221d1", "provenance": "committed-binary", "rebuildable": false },
|
||||||
|
{ "id": "brand-seal", "output": "static/assets/foundation/transparent/brand-seal.png", "width": 240, "height": 288, "alpha": true, "bytes": 101898, "sha256": "865e15edd6a1b50aa298ccdff5babf0f3140906026ce76d3a64631027820a7ae", "provenance": "committed-binary", "rebuildable": false },
|
||||||
|
{ "id": "chevron-right", "output": "static/assets/foundation/transparent/chevron-right.png", "width": 96, "height": 96, "alpha": true, "bytes": 2106, "sha256": "82f4996e118832108dba4aa33d320131fee300970679f6ce8cf4b09bda62b700", "provenance": "committed-binary", "rebuildable": false },
|
||||||
|
{ "id": "auth-backdrop", "output": "static/assets/modules/auth/opaque/sign-in-backdrop.png", "width": 824, "height": 1830, "alpha": false, "bytes": 669577, "sha256": "478d0f56d669ac1597cfc2a1082740e73550f07d655286762f3306410c19eb11", "provenance": "committed-binary", "rebuildable": false },
|
||||||
|
{ "id": "auth-header", "output": "static/assets/modules/auth/opaque/auth-header.png", "width": 824, "height": 340, "alpha": false, "bytes": 504001, "sha256": "6ce03f4962e8bdd0272ed00a82efded21becdf350a1fb0880c595d7d9c15200d", "provenance": "committed-binary", "rebuildable": false },
|
||||||
|
{ "id": "auth-password-hidden", "output": "static/assets/modules/auth/transparent/password-hidden.png", "width": 96, "height": 96, "alpha": true, "bytes": 3906, "sha256": "b9304de963cacaa5ecd133a9835174f0c77761d9f264afec900460c9aa983b4f", "provenance": "committed-binary", "rebuildable": false },
|
||||||
|
{ "id": "auth-password-visible", "output": "static/assets/modules/auth/transparent/password-visible.png", "width": 96, "height": 96, "alpha": true, "bytes": 2868, "sha256": "aa6a78b3f0ecb2d47963b9aa04c98db1b2981497f52f58ff8c602cff80fab21a", "provenance": "committed-binary", "rebuildable": false },
|
||||||
|
{ "id": "auth-icon-password", "output": "static/assets/modules/auth/transparent/icon-password.png", "width": 96, "height": 96, "alpha": true, "bytes": 2649, "sha256": "c1b13de86ffee9a9f2e7534d58ddc3bf155d5341eeddbd5998ef3747d57fdea8", "provenance": "committed-binary", "rebuildable": false },
|
||||||
|
{ "id": "auth-icon-phone", "output": "static/assets/modules/auth/transparent/icon-phone.png", "width": 96, "height": 96, "alpha": true, "bytes": 925, "sha256": "5110cfb1cc84b00145506b3cda3fc35860400d16404e606428902dfe0fa7ec2e", "provenance": "committed-binary", "rebuildable": false },
|
||||||
|
{ "id": "auth-icon-verification-code", "output": "static/assets/modules/auth/transparent/icon-verification-code.png", "width": 1254, "height": 1254, "alpha": true, "bytes": 114061, "sha256": "e932900fa8ab0edcfeed0de0d52c637890e28f58f867aabfeacbb18df060a556", "provenance": "committed-binary", "rebuildable": false },
|
||||||
|
{ "id": "auth-title-divider", "output": "static/assets/modules/auth/transparent/title-divider.png", "width": 540, "height": 90, "alpha": true, "bytes": 6278, "sha256": "10a3281723abfadcc2cec7b8e4437fe50ecdc36d885d54ebce11f46c46ed4af4", "provenance": "committed-binary", "rebuildable": false },
|
||||||
|
{ "id": "agreement-checked", "output": "static/assets/modules/auth/transparent/agreement-checked.png", "width": 96, "height": 96, "alpha": true, "bytes": 10602, "sha256": "825fcb945c81330429abc1d3ec1d0c20df86a9634416e244896e452cb08644f4", "provenance": "committed-binary", "rebuildable": false },
|
||||||
|
{ "id": "agreement-unchecked", "output": "static/assets/modules/auth/transparent/agreement-unchecked.png", "width": 96, "height": 96, "alpha": true, "bytes": 6014, "sha256": "d9843d1c3644272f6eeff9fb44e763c857f409f404551a5e1f032ddf1b07b69f", "provenance": "committed-binary", "rebuildable": false }
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"schemaVersion": 1,
|
||||||
|
"kind": "runtime-asset-inventory",
|
||||||
|
"scope": "retained-generated-assets",
|
||||||
|
"imports": [],
|
||||||
|
"assets": [
|
||||||
|
{ "id": "shared-scroll-primary-v3", "output": "static/assets/foundation/transparent/scroll-primary.png", "width": 1866, "height": 276, "alpha": true, "bytes": 576876, "sha256": "2181fece4d1743aa55b919c38ed1cb31662d8d2588f16e2dabb236e24d094040", "provenance": "committed-binary", "rebuildable": false },
|
||||||
|
{ "id": "shared-scroll-secondary-v3", "output": "static/assets/foundation/transparent/scroll-secondary.png", "width": 1866, "height": 300, "alpha": true, "bytes": 661030, "sha256": "52b8c16968e96cf457b227a3026ea9aed2b2a7b3d01ad56d409eb6b7504fc195", "provenance": "committed-binary", "rebuildable": false },
|
||||||
|
{ "id": "shared-scroll-toast-v3", "output": "static/assets/foundation/transparent/scroll-toast.png", "width": 1770, "height": 246, "alpha": true, "bytes": 468063, "sha256": "e08bdcb1ab3707762306bc23386f91562798d4efd5a5a917c3e9958fcfa7ddfc", "provenance": "committed-binary", "rebuildable": false },
|
||||||
|
{ "id": "shared-scroll-dialog-v3", "output": "static/assets/modules/auth/transparent/dialog-scroll.png", "width": 1860, "height": 1560, "alpha": true, "bytes": 355747, "sha256": "dfd0b122447e3a33737a4788efd7be35a2fca937ce600b1e8b14d12c7a9b77fa", "provenance": "committed-binary", "rebuildable": false },
|
||||||
|
{ "id": "genealogy-page-background-long", "output": "static/assets/modules/genealogy/opaque/genealogy-page-background-long.png", "width": 720, "height": 1800, "alpha": false, "bytes": 2347415, "sha256": "47e0711bced564190e7c5a28989a99f71677785f8e2d8d6cc52bcb014cba190d", "provenance": "committed-binary", "rebuildable": false },
|
||||||
|
{ "id": "tree-page-background-long", "output": "static/assets/modules/tree/opaque/tree-page-background-long.png", "width": 720, "height": 1800, "alpha": false, "bytes": 2320979, "sha256": "708523b34eb0fdb8a48125bda8f6502bd327b0ebf3bf8da874fa922e084f3b15", "provenance": "committed-binary", "rebuildable": false },
|
||||||
|
{ "id": "family-page-background-long", "output": "static/assets/modules/family/opaque/family-page-background-long.png", "width": 720, "height": 1800, "alpha": false, "bytes": 2330732, "sha256": "68e8b25335a30e44ec1208ee5db167c059f08309b2c6ec4e51a195190e3a5cee", "provenance": "committed-binary", "rebuildable": false },
|
||||||
|
{ "id": "records-page-background-long", "output": "static/assets/modules/records/opaque/records-page-background-long.png", "width": 720, "height": 1800, "alpha": false, "bytes": 2366470, "sha256": "232f9fa853041c4af0d55734b35185a43cec9856ba6277ffc8180ea09fb26040", "provenance": "committed-binary", "rebuildable": false },
|
||||||
|
{ "id": "notification-page-background-long", "output": "static/assets/modules/notification/opaque/notification-page-background-long.png", "width": 720, "height": 1800, "alpha": false, "bytes": 2159835, "sha256": "4519966d0a3c5a31f1e57aa716f810c97914e4cc81796e6b41931496edb0d05e", "provenance": "committed-binary", "rebuildable": false },
|
||||||
|
{ "id": "profile-page-background-long", "output": "static/assets/modules/profile/opaque/profile-page-background-long.png", "width": 720, "height": 1800, "alpha": false, "bytes": 2375398, "sha256": "e96a696a65a515417f5763592c48e23f75cca32fb651b76d495457c94ddb4f80", "provenance": "committed-binary", "rebuildable": false },
|
||||||
|
{ "id": "g01-empty-panel-frame", "output": "static/assets/modules/genealogy/transparent/empty-panel-frame.png", "width": 1122, "height": 1402, "alpha": true, "bytes": 74974, "sha256": "cce62602f9dc25cb9ff1ebf555efe20489513db55e2c5d0e3f762a6f11d7aa48", "provenance": "committed-binary", "rebuildable": false }
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"schemaVersion": 1,
|
||||||
|
"kind": "runtime-asset-inventory",
|
||||||
|
"scope": "schema-v3",
|
||||||
|
"imports": [
|
||||||
|
"design-pipeline/manifests/auth-runtime-assets.json",
|
||||||
|
"design-pipeline/manifests/application-runtime-assets.json",
|
||||||
|
"design-pipeline/manifests/retained-generated-runtime-assets.json"
|
||||||
|
],
|
||||||
|
"assets": []
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"name": "jiapu-design-pipeline",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {
|
||||||
|
"": {
|
||||||
|
"name": "jiapu-design-pipeline"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"name": "jiapu-design-pipeline",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"validate:runtime-assets": "node scripts/validate-runtime-asset-inventory.mjs design-pipeline/manifests/runtime-assets.json",
|
||||||
|
"check": "npm run validate:runtime-assets"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
import { createHash } from 'node:crypto'
|
||||||
|
import { readFile, readdir } from 'node:fs/promises'
|
||||||
|
import path from 'node:path'
|
||||||
|
|
||||||
|
const formalManifestKinds = new Set(['runtime-asset-inventory'])
|
||||||
|
const assertOnlyFields = (value, fields, label) => {
|
||||||
|
for (const field of Object.keys(value)) {
|
||||||
|
if (!fields.has(field)) throw new Error(`${label} contains unknown field: ${field}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolveInsideWorkspace = (workspace, candidate, label) => {
|
||||||
|
if (typeof candidate !== 'string' || candidate.trim() === '') {
|
||||||
|
throw new Error(`${label} must be a non-empty string`)
|
||||||
|
}
|
||||||
|
const absolutePath = path.resolve(workspace, candidate)
|
||||||
|
const relative = path.relative(workspace, absolutePath)
|
||||||
|
if (relative.startsWith('..') || path.isAbsolute(relative)) {
|
||||||
|
throw new Error(`${label} escapes workspace: ${candidate}`)
|
||||||
|
}
|
||||||
|
return absolutePath
|
||||||
|
}
|
||||||
|
|
||||||
|
const readManifest = async (manifestPath) => {
|
||||||
|
try {
|
||||||
|
return JSON.parse(await readFile(manifestPath, 'utf8'))
|
||||||
|
} catch (error) {
|
||||||
|
if (error.code === 'ENOENT') throw new Error(`missing manifest: ${manifestPath}`)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const validateDirectAsset = (asset, workspace) => {
|
||||||
|
if (!asset || typeof asset !== 'object' || Array.isArray(asset)) throw new Error('runtime asset must be an object')
|
||||||
|
assertOnlyFields(
|
||||||
|
asset,
|
||||||
|
new Set(['id', 'output', 'width', 'height', 'alpha', 'bytes', 'sha256', 'provenance', 'rebuildable']),
|
||||||
|
'runtime asset',
|
||||||
|
)
|
||||||
|
for (const key of ['id', 'output']) {
|
||||||
|
if (typeof asset[key] !== 'string' || asset[key].trim() === '') {
|
||||||
|
throw new Error(`runtime asset ${key} must be a non-empty string`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
resolveInsideWorkspace(workspace, asset.output, `${asset.id}.output`)
|
||||||
|
for (const key of ['width', 'height', 'bytes']) {
|
||||||
|
if (!Number.isInteger(asset[key]) || asset[key] <= 0) throw new Error(`${asset.id}.${key} must be a positive integer`)
|
||||||
|
}
|
||||||
|
if (typeof asset.alpha !== 'boolean') throw new Error(`${asset.id}.alpha must be boolean`)
|
||||||
|
if (asset.provenance !== 'committed-binary') {
|
||||||
|
throw new Error(`${asset.id}.provenance must be committed-binary`)
|
||||||
|
}
|
||||||
|
if (asset.rebuildable !== false) throw new Error(`${asset.id}.rebuildable must be false`)
|
||||||
|
if (!/^[a-f0-9]{64}$/.test(asset.sha256)) throw new Error(`${asset.id}.sha256 must be lowercase SHA256`)
|
||||||
|
return asset
|
||||||
|
}
|
||||||
|
|
||||||
|
export const expandRuntimeAssetInventory = async (rootManifestPath, workspace) => {
|
||||||
|
const workspacePath = path.resolve(workspace)
|
||||||
|
const loaded = new Set()
|
||||||
|
const stack = []
|
||||||
|
const ids = new Map()
|
||||||
|
const outputs = new Map()
|
||||||
|
const manifests = []
|
||||||
|
|
||||||
|
const addAsset = (asset, owner) => {
|
||||||
|
if (ids.has(asset.id)) {
|
||||||
|
throw new Error(`duplicate asset id: ${asset.id} (${ids.get(asset.id)}, ${owner})`)
|
||||||
|
}
|
||||||
|
if (outputs.has(asset.output)) {
|
||||||
|
throw new Error(`duplicate output: ${asset.output} (${outputs.get(asset.output).owner}, ${owner})`)
|
||||||
|
}
|
||||||
|
ids.set(asset.id, owner)
|
||||||
|
outputs.set(asset.output, { ...asset, owner })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 递归展开只处理机器清单之间的依赖;页面消费者始终从源码扫描得到,避免双写。
|
||||||
|
const visit = async (manifestPath) => {
|
||||||
|
const absolutePath = resolveInsideWorkspace(workspacePath, manifestPath, 'manifest')
|
||||||
|
if (stack.includes(absolutePath)) {
|
||||||
|
throw new Error(`import cycle: ${[...stack, absolutePath].join(' -> ')}`)
|
||||||
|
}
|
||||||
|
if (loaded.has(absolutePath)) throw new Error(`duplicate import: ${absolutePath}`)
|
||||||
|
|
||||||
|
stack.push(absolutePath)
|
||||||
|
loaded.add(absolutePath)
|
||||||
|
manifests.push(absolutePath)
|
||||||
|
const manifest = await readManifest(absolutePath)
|
||||||
|
|
||||||
|
if (manifest.kind === 'runtime-asset-inventory') {
|
||||||
|
assertOnlyFields(manifest, new Set(['schemaVersion', 'kind', 'scope', 'imports', 'assets']), 'runtime manifest')
|
||||||
|
if (manifest.schemaVersion !== 1) throw new Error('runtime inventory schemaVersion must be 1')
|
||||||
|
if (typeof manifest.scope !== 'string' || manifest.scope.trim() === '') throw new Error('runtime inventory scope is required')
|
||||||
|
if (!Array.isArray(manifest.imports) || !Array.isArray(manifest.assets)) {
|
||||||
|
throw new Error('runtime inventory imports and assets must be arrays')
|
||||||
|
}
|
||||||
|
for (const asset of manifest.assets) addAsset(validateDirectAsset(asset, workspacePath), absolutePath)
|
||||||
|
for (const imported of manifest.imports) await visit(imported)
|
||||||
|
} else {
|
||||||
|
throw new Error(`unsupported manifest kind: ${manifest.kind}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
stack.pop()
|
||||||
|
}
|
||||||
|
|
||||||
|
await visit(path.relative(workspacePath, path.resolve(rootManifestPath)))
|
||||||
|
return { manifests, assets: [...outputs.values()] }
|
||||||
|
}
|
||||||
|
|
||||||
|
export const validateRuntimeAssetRegistry = async (rootManifestPath, workspace, manifestsDirectory) => {
|
||||||
|
const workspacePath = path.resolve(workspace)
|
||||||
|
const directory = resolveInsideWorkspace(workspacePath, manifestsDirectory, 'manifests directory')
|
||||||
|
const rootManifest = await readManifest(path.resolve(rootManifestPath))
|
||||||
|
if (rootManifest.kind !== 'runtime-asset-inventory' || rootManifest.scope !== 'schema-v3') {
|
||||||
|
throw new Error('registry root scope must be schema-v3')
|
||||||
|
}
|
||||||
|
const inventory = await expandRuntimeAssetInventory(rootManifestPath, workspacePath)
|
||||||
|
const registered = new Set(inventory.manifests.map((manifest) => path.resolve(manifest)))
|
||||||
|
|
||||||
|
for (const asset of inventory.assets) {
|
||||||
|
let binary
|
||||||
|
try {
|
||||||
|
binary = await readFile(resolveInsideWorkspace(workspacePath, asset.output, `${asset.id}.output`))
|
||||||
|
} catch (error) {
|
||||||
|
if (error.code === 'ENOENT') throw new Error(`missing runtime asset: ${asset.output}`)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
if (binary.length !== asset.bytes) {
|
||||||
|
throw new Error(`${asset.id}.bytes does not match ${asset.output}`)
|
||||||
|
}
|
||||||
|
const digest = createHash('sha256').update(binary).digest('hex')
|
||||||
|
if (digest !== asset.sha256) {
|
||||||
|
throw new Error(`${asset.id}.sha256 does not match ${asset.output}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 顶层注册表必须覆盖目录内每一份正式 owner。这样新增清单若没有接入全局图会立即失败,
|
||||||
|
// output 与 id 的唯一性也就不再局限于某个业务域的 imports 闭包。
|
||||||
|
for (const entry of await readdir(directory, { withFileTypes: true })) {
|
||||||
|
if (!entry.isFile() || path.extname(entry.name).toLowerCase() !== '.json') continue
|
||||||
|
const manifestPath = path.join(directory, entry.name)
|
||||||
|
const manifest = await readManifest(manifestPath)
|
||||||
|
if (formalManifestKinds.has(manifest.kind)) {
|
||||||
|
if (!registered.has(path.resolve(manifestPath))) throw new Error(`unregistered manifest: ${manifestPath}`)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`undeclared legacy manifest: ${manifestPath}`)
|
||||||
|
}
|
||||||
|
return inventory
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import path from 'node:path'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
|
||||||
|
import { validateRuntimeAssetRegistry } from './runtime-asset-inventory.mjs'
|
||||||
|
|
||||||
|
const scriptDirectory = path.dirname(fileURLToPath(import.meta.url))
|
||||||
|
const workspace = path.resolve(scriptDirectory, '..', '..')
|
||||||
|
const manifestArgument = process.argv[2]
|
||||||
|
if (!manifestArgument) throw new Error('Usage: node validate-runtime-asset-inventory.mjs <workspace-relative-manifest>')
|
||||||
|
|
||||||
|
const inventory = await validateRuntimeAssetRegistry(
|
||||||
|
path.resolve(workspace, manifestArgument),
|
||||||
|
workspace,
|
||||||
|
path.join(workspace, 'design-pipeline', 'manifests'),
|
||||||
|
)
|
||||||
|
process.stdout.write(`RUNTIME ASSET CHECK PASS manifests=${inventory.manifests.length} assets=${inventory.assets.length}\n`)
|
||||||
@@ -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,265 +0,0 @@
|
|||||||
# D1:安卓视觉规范与页面壳
|
|
||||||
|
|
||||||
> 对应《规划》:P-02、P-03、P-04
|
|
||||||
> 适用范围:Android 手机端全部页面
|
|
||||||
> 设计锚点:用户提供的“汤氏家谱世系图”参考图
|
|
||||||
|
|
||||||
## 1. 设计锚点与不可偏离项
|
|
||||||
|
|
||||||
参考图不是只借用“红色”,而是整套视觉语言的依据。后续每一页必须具备以下共性:
|
|
||||||
|
|
||||||
1. **家祠红头图**:核心家谱页面使用朱砂红顶部,左侧为低对比古建筑线描,标题使用金色或暖白色。
|
|
||||||
2. **宣纸正文区**:主内容始终落在暖白纸面上,背景只允许极浅的山峦、竹影、亭台或纸纤维纹理,不能影响文字阅读。
|
|
||||||
3. **墨字与古金线**:正文使用墨褐色,分隔、牌位、题签和次级结构使用古金细线。
|
|
||||||
4. **礼制化状态**:当前选中、重要操作与未读提醒使用朱砂;不可用荧光色、大面积渐变或通用蓝色按钮替代。
|
|
||||||
5. **牌位不是卡片**:世系树、人物录核心信息使用竖式牌位;其他列表保留纸签和金线的秩序感,不能变成白底大圆角信息卡。
|
|
||||||
|
|
||||||
### 1.1 一比一还原的验收定义
|
|
||||||
|
|
||||||
“一比一”指以参考图为唯一视觉锚点,不能只借用红色后另行发挥。以 `360 × 800dp` 设计画布验收时,必须同时还原:朱砂红头的高度与留白、祠堂线描的位置和低对比度、金色标题比例、宣纸与山水底纹、古金牌位线框、朱砂选中描边、纸面底部抽屉与底部导航的层级效果。
|
|
||||||
|
|
||||||
不同业务页的内容结构可以不同,但不能把世系图页面原样复制到每个页面;它们必须在同一套材质、颜色、字体、线框和动效逻辑中变化。为适配 `320 × 568` 等小屏,元素可流式换行或缩放,**不得**为追求固定像素而造成重叠或不可点击。
|
|
||||||
|
|
||||||
每张后续设计稿都要和参考图并排核对上述八项;没有通过核对,不能标为设计完成。
|
|
||||||
|
|
||||||
## 2. Android 基础尺寸
|
|
||||||
|
|
||||||
设计坐标以 `750rpx` 宽度为基准。实现时允许流式收缩,但不得缩小关键点击区。
|
|
||||||
|
|
||||||
| 元素 | 设计值 | 小屏规则 |
|
|
||||||
| --- | ---: | --- |
|
|
||||||
| 页面左右安全边距 | `32rpx` | `320px` 小屏可缩至 `24rpx`。 |
|
|
||||||
| 页面标题 | `34rpx` | 不换成两行;长标题使用省略号。 |
|
|
||||||
| 主标题 | `48rpx` | 家谱名最大两行,超出截断。 |
|
|
||||||
| 正文 | `28rpx` | 不低于 `24rpx`。 |
|
|
||||||
| 辅助文字 | `24rpx` | 仅用于时间、状态、说明。 |
|
|
||||||
| 主按钮高度 | `88rpx` | 最小点击高度,不因小屏减小。 |
|
|
||||||
| 图标触控框 | `88rpx × 88rpx` | 图标视觉大小可为 `40–48rpx`。 |
|
|
||||||
| 列表行高度 | `104rpx` 起 | 多行信息自动增高。 |
|
|
||||||
| 底部操作栏 | `128rpx` 起 | 加系统导航保底内边距。 |
|
|
||||||
|
|
||||||
## 3. 色彩、字体与图标
|
|
||||||
|
|
||||||
### 3.1 色彩
|
|
||||||
|
|
||||||
| Token | 色值 | 使用位置 |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| `paper-page` | `#F6F0E5` | 页面整体纸底。 |
|
|
||||||
| `paper-surface` | `#FFF9EE` | 抽屉、表单、列表承载面。 |
|
|
||||||
| `cinnabar` | `#A33B2B` | 主操作、选中描边、徽章。 |
|
|
||||||
| `cinnabar-deep` | `#7E251C` | 顶部红底的层次和按下态。 |
|
|
||||||
| `ink` | `#342A24` | 标题、主正文。 |
|
|
||||||
| `ink-muted` | `#7A6958` | 次级正文、时间。 |
|
|
||||||
| `antique-gold` | `#B58A4B` | 牌位描边、细分割线、装饰。 |
|
|
||||||
| `gold-soft` | `#E4D4B8` | 弱分割线、未选中轮廓。 |
|
|
||||||
| `danger` | `#B63A2C` | 删除、审核拒绝;不额外引入鲜红。 |
|
|
||||||
|
|
||||||
绿色不进入页面的背景、导航、主按钮或状态色。若用户照片中含绿色,保持原图,不再叠加绿色 UI 元素。
|
|
||||||
|
|
||||||
### 3.2 字体
|
|
||||||
|
|
||||||
- 页面标题、家谱名、人物姓名:系统衬线回退链 `serif`;仅用于短标题和牌位姓名,体现家谱的书卷感。
|
|
||||||
- 正文、表单、数字、按钮:Android 系统无衬线字体 `sans-serif`;优先保证低版本设备的字形和行高稳定。
|
|
||||||
- 不下载网络字体,不依赖单一厂商字体,不用细于常规字重的中文字体。
|
|
||||||
- 姓名为两至四字时居中竖排;超过四字改为横排或两行,禁止压缩字距造成难读。
|
|
||||||
|
|
||||||
### 3.3 透明 PNG 图标规范
|
|
||||||
|
|
||||||
- 图标文件一律是 RGBA PNG,四角透明;不把乳白或绿色底色烘焙进图标。
|
|
||||||
- 功能图标以官方 `24dp × 24dp` 视口为唯一留白标准,导出为 `96 × 96px`(4 倍)透明 PNG;不得再从大幅生成图中裁出图标、不得人为叠加 `25%` 以上的额外空白。图标的边距只保留官方图形本身的标准边距。
|
|
||||||
- 正常态使用墨褐或古金单色;选中态使用朱砂;不使用渐变、投影或多色填充。
|
|
||||||
- 图标命名采用 `功能-状态.png`,例如 `tree-active.png`、`notice-default.png`、`profile-active.png`。
|
|
||||||
- 首批需要的图标:返回、搜索、更多、通知、家谱、家族、我的、世系图、列表、编辑、新增、相册、礼仪、消息、设置、关闭。
|
|
||||||
|
|
||||||
### 3.4 资产优先规则
|
|
||||||
|
|
||||||
- 所有具备明确动作或状态的元素必须使用透明 PNG 图标:导航、返回、搜索、通知、更多、关闭、编辑、新增、上传、相册、成员、世系、礼仪、设置、删除、分享、点赞、评论。
|
|
||||||
- 禁止使用 Emoji、Unicode 符号、临时文字(例如“返回”“更多”)替代功能图标;功能图标旁的文字仅用于解释,不承担唯一识别职责。
|
|
||||||
- 文字只保留在用户必须阅读或选择的内容上:页面标题、家谱名称、成员资料、表单标签、状态说明与按钮动作。
|
|
||||||
- 每一个图标必须有默认态与需要时的选中态;选中态仅改变为朱砂色,不改变图形轮廓,保证低端 Android 设备上也能一眼识别。
|
|
||||||
- **后续强制规则**:任何后续页面设计中新增加的图标,必须同步生成对应的 RGBA 透明 PNG,保存到项目 `static/assets/icons/` 后才能将该页面标记为“设计完成”。设计图中的图形、文字占位、Emoji 或系统默认图标均不能替代实际资产。
|
|
||||||
- 每个新增图标都要在资产清单登记:用途、文件路径、默认 / 选中状态、设计页编号和透明背景检查结果;页面实现只能引用清单中的已验收资产。
|
|
||||||
|
|
||||||
### 3.4.1 功能图标的标准骨架与视觉风格
|
|
||||||
|
|
||||||
- Android / Material 的在线规范用于锁定**图标语义、24dp 视口、触控区、默认 / 选中状态和可识别性**,不是要求本项目的每个图标都长成默认黑色 Material 图标。
|
|
||||||
- 项目 Logo、祠堂纹样、宣纸、山水竹影属于品牌与背景插画,可以定制生成;顶部和通用操作图标允许采用本项目的“古金双线 + 克制云纹收口”风格,风格锚点见 [公共操作图标风格锚点](references/公共操作图标-风格锚点.png)。
|
|
||||||
- 自定义图标仍须保留标准语义:返回一眼识别为左箭头、搜索为放大镜、通知为铃铛、更多为三点、关闭为 X;不得因装饰而改变动作含义。
|
|
||||||
- 底部导航固定为三项且每项同时显示图标与短标签:`家谱`、`家族`、`我的`。现有 Tab 图标不通过验收,必须重设计为等视觉重量的简洁轮廓;不能出现云纹、枝叶和双层复杂装饰。选中项使用同一图形的朱砂色与填充 / 加粗状态,非选中项用墨褐或古金描边。
|
|
||||||
- 顶部与通用操作采用标准语义:返回、搜索、通知、更多、关闭、新增、编辑、上传、删除、分享、设置。图标可定制风格,但不以文字、Emoji 或难以识别的自造图形替代。
|
|
||||||
|
|
||||||
### 3.4.2 按钮图标风格分级
|
|
||||||
|
|
||||||
- **大部分按钮与顶部操作**:返回、搜索、通知、更多、关闭、新增、编辑、上传、删除、分享、设置,以及家谱 / 家族模块中的快捷入口,采用“古金双线 + 轻云纹收口”风格。
|
|
||||||
- **主操作按钮**:图标可置于“新建家谱、保存、发布、提交”等文字前;图标为古金双线简化版,按钮本身仍以朱砂描边或朱砂实底表达主次,不用纯文字按钮。
|
|
||||||
- **底部 Tab**:不得直接使用复杂云纹版本;使用更简洁的同色系轮廓,优先保证 24dp 识别度和三个目的地的差异。
|
|
||||||
- **状态与危险操作**:成功、失败、删除、无权限等不使用花纹干扰信息;保留标准清晰的符号,颜色按状态区分。
|
|
||||||
|
|
||||||
> 来源依据:Google Material Symbols 官方库提供适用于 Android 的 PNG / SVG 图标、`FILL` 状态轴和 20–48dp 光学尺寸;Android 官方将导航栏用于 3–5 个同等级目的地。项目以这些规则做底线,并在其上保留家祠卷轴的视觉个性。
|
|
||||||
|
|
||||||
### 3.5 图标资产清单与路径
|
|
||||||
|
|
||||||
所有最终资产均保存在 `static/assets/icons/`,由页面直接引用;不使用在线 URL。
|
|
||||||
|
|
||||||
| 资产组 | 文件 | 状态 |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| 项目标识 | `brand/jiapu-seal-logo.png` | 自定义生成,待缩小尺寸验收 |
|
|
||||||
| 顶部操作 | `common/back.png`、`common/search.png`、`common/notice.png`、`common/more.png`、`common/close.png` | 保留已确认的古金双线风格,待逐个紧凑导出 |
|
|
||||||
| 底部导航 | `tab/genealogy.png`、`tab/family.png`、`tab/profile.png` 及各自 `-active.png` | 待按标准 24dp 语义重绘为简洁同风格版本 |
|
|
||||||
| 家谱操作 | `genealogy/tree.png`、`members.png`、`generation-poem.png`、`application.png`、`create.png`、`edit.png` | 页面设计时按 Material Symbols 官方源导出 |
|
|
||||||
| 家族内容 | `family/feed.png`、`article.png`、`album.png`、`ceremony.png`、`gift.png`、`archive.png` | 页面设计时按 Material Symbols 官方源导出 |
|
|
||||||
| 通用操作 | `action/add.png`、`upload.png`、`delete.png`、`share.png`、`like.png`、`comment.png`、`setting.png` | 待按古金双线风格逐个紧凑导出 |
|
|
||||||
|
|
||||||
### 3.5 已否决的资产(不得引用)
|
|
||||||
|
|
||||||
`static/assets/icons/common/`、`static/assets/icons/tab/`、`static/assets/icons/action/` 当前是从大幅绿色底图裁切的原始试验文件,仍不得直接引用。其问题是**画布和缩放方式**,不是用户已确认的顶部操作图标美术方向。
|
|
||||||
|
|
||||||
**根因记录**:这些文件来自带大块绿色空白的 3 × 2 AI 图稿,再按格子裁切;生成图没有稳定的标准视口,导致图标周围出现过多空白、主体大小不一致。顶部操作图标的古金双线与轻云纹风格保留;Tab 图标的复杂枝叶、过多装饰和不统一轮廓不保留。
|
|
||||||
|
|
||||||
**新生产方式**:每个图标单独制作并紧凑导出到 `96px` PNG,实际图形占官方 24dp 视口的正常范围;不得再批量盲裁。顶部 / 操作图标以风格锚点为准,Tab 按简洁语义重新制作。每个文件在 `46rpx` 实际显示尺寸检查后才可引用。
|
|
||||||
|
|
||||||
### 3.6 功能图标验收
|
|
||||||
|
|
||||||
功能图标只有同时满足以下条件,才可标记为“已生成”:
|
|
||||||
|
|
||||||
1. 语义、视口与状态符合 Google Material Symbols / Android 规范;若为本项目定制图标,记录其对应的标准语义与风格锚点。
|
|
||||||
2. 默认 / 选中态为同一语义符号,保持 `24dp` 标准画布与固定留白;不得改变动作含义来冒充选中态。
|
|
||||||
3. 项目内文件为 RGBA 透明 PNG,四角透明;默认墨褐、选中朱砂;在纸白底和红头上均满足清晰可见。
|
|
||||||
4. 在 `46rpx` Tab 显示尺寸及 `320 × 568` 小屏中检查可识别性、标签不换行、点击区不小于 `88rpx`。
|
|
||||||
|
|
||||||
### 3.7 背景资产规则
|
|
||||||
|
|
||||||
- 所有装饰性背景都必须生成并保存在项目 `static/assets/backgrounds/`;禁止依赖网络背景图,也不能只用临时 CSS 渐变假装纸纹。
|
|
||||||
- 背景分层使用:`header-hall-lineart.png` 用于朱砂顶部;`paper-rice-texture.jpg` 用于宣纸底;`footer-mountain-bamboo.png` 用于页面底部的淡山水竹影。页面按需组合,不把它们烘焙成一张全屏超大图。
|
|
||||||
- 祠堂线描、山水、竹影等叠加背景应使用透明 PNG;宣纸纸纹使用压缩后的 JPG。所有背景必须无文字、无 Logo、无人物、无绿色调。
|
|
||||||
- 为兼容老款 Android:顶部纹样显示尺寸不超过 `750 × 288rpx`,底部山水显示尺寸不超过 `750 × 360rpx`;背景只在首屏和固定区域加载,不在长列表中重复解码。
|
|
||||||
- 背景完成验收需检查:`320 × 568` 下不遮挡标题或操作、纸纹不影响文字对比度、图片已压缩到适合移动端的尺寸。
|
|
||||||
- **页面背景完成条件**:每一个页面必须在其设计记录中写清“红头 / 纸纹 / 山水竹影 / 侧边竹影 / 无”的组合与对应资产路径;没有背景组合的页面不得标记为“设计完成”。背景可以复用基础资产,但不能是未设计的纯色空白页面或临时 CSS 渐变。
|
|
||||||
|
|
||||||
| 页面类型 | 必须使用的背景组合 |
|
|
||||||
| --- | --- |
|
|
||||||
| 家谱总览、世系树、成员档案 | 朱砂红头 + 祠堂线描 + 宣纸纸纹;底部或抽屉使用淡山水竹影。 |
|
|
||||||
| 家族动态、谱文、相册、礼仪、消息 | 紧凑朱砂题签 + 宣纸纸纹;内容尾部按需叠加竹影或山水。 |
|
|
||||||
| 创建、编辑、申请、设置、反馈 | 紧凑题签 + 宣纸纸纹;表单区域保持干净,不让山水压住输入内容。 |
|
|
||||||
| 登录、注册、找回密码 | 独立生成的红头 / 纸纹欢迎背景;不能直接复用列表页大山水。 |
|
|
||||||
| 无数据、失败、审核中 | 宣纸纸纹 + 对应低对比卷轴 / 印章背景资产;不能只显示文字。 |
|
|
||||||
|
|
||||||
| 背景资产 | 路径 | 用途 | 状态 |
|
|
||||||
| --- | --- | --- | --- |
|
|
||||||
| 祠堂线描 | `backgrounds/header-hall-lineart.png` | 家谱红头左侧或底部纹样 | 生成中 |
|
|
||||||
| 宣纸纸纹 | `backgrounds/paper-rice-texture.jpg` | 全局纸面底色 | 未生成 |
|
|
||||||
| 山水竹影 | `backgrounds/footer-mountain-bamboo.png` | 列表页、抽屉页底部装饰 | 未生成 |
|
|
||||||
|
|
||||||
## 4. 公共页面壳
|
|
||||||
|
|
||||||
### 4.1 核心家谱页面壳
|
|
||||||
|
|
||||||
适用于:家谱总览、世系图、成员目录、人物档案。
|
|
||||||
|
|
||||||
- 顶部 `256–288rpx`:朱砂红,左下放置低对比祠堂屋檐线描,禁止使用满屏建筑照片。
|
|
||||||
- 根页面左上角放项目 Logo(祠堂屋檐 + “谱”字印章,透明 PNG,视觉 `40rpx`、触控区 `88rpx`);详情页该位置优先保留返回按钮,Logo 收入建筑纹样中的小印记。
|
|
||||||
- 搜索、通知、更多均为 `88rpx` 触控框;图标为暖白或金色线稿 PNG。通知铃铛仅在未读消息数大于 0 时显示朱砂小圆点;未读为 0 时不显示圆点和数字。
|
|
||||||
- 家谱名称位于视觉中心,最大两行;名称下可放地域或代系提示,但不能与图标重叠。
|
|
||||||
- 红头结束后立即进入纸面 Tab/分段导航,使用古金底线标记当前项。
|
|
||||||
|
|
||||||
### 4.2 普通列表与详情页面壳
|
|
||||||
|
|
||||||
适用于:家族动态、谱文、相册、人物录、礼仪、消息、我的。
|
|
||||||
|
|
||||||
- 顶部使用 `144–176rpx` 紧凑朱砂题签,保留建筑线描的局部纹样。
|
|
||||||
- 内容区采用纸白承载面与古金细分割线,列表项不使用夸张圆角;圆角最大 `16rpx`。
|
|
||||||
- 每页只保留一个视觉主操作:新增、发布、保存或提交。它固定在底部操作栏或右下角,不能同时出现两个醒目的红按钮。
|
|
||||||
|
|
||||||
### 4.3 表单与编辑页面壳
|
|
||||||
|
|
||||||
适用于:创建家谱、录入始祖、申请加入、发布内容、编辑成员、反馈、设置。
|
|
||||||
|
|
||||||
- 顶部为紧凑题签,正文使用逐行纸签式表单,标签在左、输入值在右或下一行。
|
|
||||||
- 必填项用朱砂小印记表示;校验说明用暖灰褐,不使用大面积红色报错框。
|
|
||||||
- 键盘出现时页面可滚动,底部“保存 / 提交”保持可操作。
|
|
||||||
|
|
||||||
### 4.4 底部导航与抽屉
|
|
||||||
|
|
||||||
- 三 Tab:家谱、家族、我的。纸白底、顶部古金细线;当前图标和文字用朱砂,未选中用墨褐。
|
|
||||||
- 无底部 Tab 的详情页,底部保留返回区域或单主操作栏,不能出现空白黑条。
|
|
||||||
- 成员、相册、礼仪的详情采用半屏至全屏底部抽屉:纸白底、顶部金色短握条、右上关闭按钮、淡竹影或山影作为低对比装饰。
|
|
||||||
|
|
||||||
## 5. 核心组件规则
|
|
||||||
|
|
||||||
### 5.1 世系人物牌位
|
|
||||||
|
|
||||||
- 牌位为暖白底、古金双线边框、上下折角;不使用普通矩形卡片。
|
|
||||||
- 始祖牌位比同层成员高一级;当前选中人物外框换成朱砂双线,并使用小圆点连接标识。
|
|
||||||
- 牌内信息顺序:姓名 → 字 / 号 → 生卒或年份;信息不足时显示“生卒不详”,不留大段空白。
|
|
||||||
- 代际标签固定在树左侧,不随横向滚动丢失;横向树区域与底部档案抽屉互不遮挡。
|
|
||||||
|
|
||||||
### 5.2 纸签列表
|
|
||||||
|
|
||||||
- 左侧为功能图标或缩略图,中间为标题和两行信息,右侧是状态或箭头。
|
|
||||||
- 每行以古金细线分隔;红色仅用于“待审核”“未读”“进行中”等需要注意的状态。
|
|
||||||
- 长标题最多两行;日期、人数、地域等元信息单独一行,不与操作按钮抢空间。
|
|
||||||
|
|
||||||
### 5.3 主操作与次操作
|
|
||||||
|
|
||||||
- 主操作:朱砂描边或朱砂实底,圆角 `44rpx` 的胶囊形;文字白色或纸白。
|
|
||||||
- 次操作:纸白底、古金描边、墨褐文字。
|
|
||||||
- 删除和退出:先显示纸面确认抽屉,再允许使用 `danger` 色;不可直接用红色文字替代确认流程。
|
|
||||||
|
|
||||||
### 5.4 空状态与异常
|
|
||||||
|
|
||||||
- 空状态使用纸纹中的淡印章、卷轴、山影或未展开牌位,不使用卡通人物。
|
|
||||||
- 网络失败使用“纸页未送达”图标、明确重试按钮;不把错误码直接展示给用户。
|
|
||||||
- 审核中使用朱砂印章和预计提示;无权限使用合拢卷轴与申请/返回入口。
|
|
||||||
|
|
||||||
## 6. 首屏结构:家谱首页(G-01)
|
|
||||||
|
|
||||||
G-01 是所有设计页的公共还原样板,不等同于最终代码。
|
|
||||||
|
|
||||||
1. 顶部:朱砂祠堂红头,左侧项目 Logo,中央为“我的家谱”,右侧通知 PNG 图标;只有未读消息时才带朱砂小圆点。
|
|
||||||
2. 主区:当前家谱以横向家谱题签展示名称、地域、成员数和身份;这个题签的朱砂印章代表“当前选中的家谱”,不是另一种家谱颜色。没有家谱时替换为卷轴空状态。
|
|
||||||
3. 快捷区:世系图、成员、字辈诗、申请审核四项,使用透明 PNG 图标与古金分隔。
|
|
||||||
4. 家谱列表:明确分为“我创建的”和“我加入的”两个标题区;每项使用同一套纸签和古金线,不用红色 / 金色印章暗示未说明的身份差异。当前选中项可用朱砂细边标记。
|
|
||||||
5. 底部:三 Tab;主操作“创建家谱”在内容末尾或空状态中出现,避免遮挡 Tab。
|
|
||||||
|
|
||||||
### 6.1 G-01 数据文案规则
|
|
||||||
|
|
||||||
- 家谱名称后不得出现无意义横线、破折号、模拟字符或“待补充”占位。
|
|
||||||
- 有数据时按真实字段显示,例如“河南省 · 洛阳市”“158 位成员”“管理员”;无数据时整行隐藏,或显示明确的“地区待完善 / 成员待录入”。
|
|
||||||
- “当前家谱”与下方列表不得无说明地重复同一名称;当前题签显示选中的家谱,下方列表用于切换,选中项需要清晰标记。
|
|
||||||
|
|
||||||
### 6.2 G-01 固定设计栅格(360 × 800dp)
|
|
||||||
|
|
||||||
这是后续页面还原的硬尺寸基准,不再交给整页 AI 图自行猜测:
|
|
||||||
|
|
||||||
| 区域 | 尺寸 / 间距 | 约束 |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| 系统状态栏 | `24dp` | 只预留系统空间,不放页面内容。 |
|
|
||||||
| 家祠红头 | `112dp`(含标题区) | Logo 左 `16dp`,标题居中,铃铛右 `16dp`;红头结束后不得出现大块空白。 |
|
|
||||||
| 当前家谱题签 | 顶距 `16dp`,高 `136dp`,左右 `16dp` | 只展示当前家谱一次;名称、成员数、地域、身份均为真实字段。 |
|
|
||||||
| 快捷入口 | 题签后 `16dp`,行高 `84dp` | 四项等宽;图标实际 `24dp`,标签 `14sp`,不使用大号装饰图。 |
|
|
||||||
| 分隔与列表标题 | 上距 `16dp`,标题区 `36dp` | 细古金线 + “我创建的 / 我加入的”,不能留无内容的整屏空白。 |
|
|
||||||
| 家谱列表项 | 每项 `72dp`,项间 `8dp` | 一项只显示名称、必要元信息、右箭头;不重复当前题签内容。 |
|
|
||||||
| 新建家谱 | 列表后 `16dp`,高 `48dp` | 朱砂描边胶囊按钮,不用巨大的上下留白烘托。 |
|
|
||||||
| 底部导航 | `64dp` + 系统导航保底 | 纸白底、三项等宽,图标 `24dp`、标签 `12sp`、每项触控区至少 `48dp`。 |
|
|
||||||
|
|
||||||
上述内容总高度在 `360 × 800dp` 首屏内保持紧凑;数据较多时由内容区滚动,而不是拉大首屏留白。`320 × 568` 下题签和快捷入口仍在首屏可见,列表向下滚动。
|
|
||||||
|
|
||||||
### 6.3 已否决的 G-01 生成整页图
|
|
||||||
|
|
||||||
此前 AI 生成的“我的家谱”整页图**保留为美术锚点**:[G01 我的家谱美术锚点](references/G01-我的家谱-美术锚点.png)。它明确了本项目应该保留的整体效果:朱砂祠堂红头、暖金标题、宣纸纸纹、金线题签、淡山水竹影、纸白底部导航和庄重的色彩层级。
|
|
||||||
|
|
||||||
该图**不直接作为尺寸与功能验收稿**,因此不计入 G-01 的“页面设计完成”。需要按 6.2 的固定栅格修正:红头后与题签之间的过大留白、快捷入口与列表之间的过大留白、缺失的左上 Logo、当前家谱与列表的重复、无意义横线占位、以及非标准来源图标。
|
|
||||||
|
|
||||||
从此以后,页面设计采用“双锚点”:本图锁定美术氛围和视觉效果;6.2 固定栅格锁定尺寸、内容密度与 Android 小屏可用性。AI 只可生成 Logo、背景等独立视觉资产,不能决定页面间距、文字排版、列表高度或交互层级。
|
|
||||||
|
|
||||||
### 6.4 标准来源验收备注
|
|
||||||
|
|
||||||
- Material Symbols 官方指南:<https://developers.google.com/fonts/docs/material_symbols>
|
|
||||||
- Android Navigation Bar 官方指南:<https://developer.android.com/develop/ui/compose/components/navigation-bar>
|
|
||||||
|
|
||||||
## 7. 设计状态记录
|
|
||||||
|
|
||||||
- P-02:完成。颜色、字体、尺寸、透明 PNG 图标规范已锁定。
|
|
||||||
- P-03:完成。核心、普通、表单三类页面壳及底部导航、抽屉已锁定。
|
|
||||||
- P-04:完成。加载、空数据、失败、无权限、审核中与确认操作规则已锁定。
|
|
||||||
- 下一阶段:D2 账户与首次使用页面;全部页面均按本规范出高保真稿后,才进入 uni-app 还原。
|
|
||||||
@@ -1,106 +0,0 @@
|
|||||||
# G-01:我的家谱设计记录
|
|
||||||
|
|
||||||
> 状态:资产准备中
|
|
||||||
> 美术锚点:[G01 我的家谱美术锚点](references/G01-我的家谱-美术锚点.png)
|
|
||||||
> 布局锚点:[D1 安卓视觉规范与页面壳](D1_安卓视觉规范与页面壳.md#62-g-01-固定设计栅格360--800dp)
|
|
||||||
|
|
||||||
## 页面目标
|
|
||||||
|
|
||||||
让已登录用户一眼查看当前家谱、进入四个高频入口、切换已创建 / 已加入家谱,并在无家谱时创建或搜索加入。
|
|
||||||
|
|
||||||
## 背景组合
|
|
||||||
|
|
||||||
- 红头:`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 新截图完成视觉验收。
|
|
||||||
|
Before Width: | Height: | Size: 1.8 MiB |
|
Before Width: | Height: | Size: 1.3 MiB |
|
Before Width: | Height: | Size: 2.1 MiB |
|
Before Width: | Height: | Size: 2.0 MiB |
|
Before Width: | Height: | Size: 1.9 MiB |
|
Before Width: | Height: | Size: 1.8 MiB |
|
Before Width: | Height: | Size: 1.8 MiB |
@@ -1,62 +0,0 @@
|
|||||||
# 真实接口核心流程 Implementation Plan
|
|
||||||
|
|
||||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
||||||
|
|
||||||
**Goal:** 让家谱 App 的认证、当前家谱上下文、创建首代和世系浏览形成可验证的 Mock/远端双模式核心闭环。
|
|
||||||
|
|
||||||
**Architecture:** `utils/config.js` 显式决定数据模式;`utils/api.js` 是唯一远端响应解包和错误转换位置;`utils/session.js` 与新增的上下文模块持久化 token 和当前家谱 ID。页面只调用 `appApi` 并从路由读取对象 ID。
|
|
||||||
|
|
||||||
**Tech Stack:** uni-app Vue 3、HBuilderX、uni.request、PowerShell 合同测试。
|
|
||||||
|
|
||||||
## Global Constraints
|
|
||||||
|
|
||||||
- 不将测试账号、密码或 token 写入源码、测试或文档。
|
|
||||||
- 远端模式失败必须显示错误,不能自动返回 Mock 数据。
|
|
||||||
- 所有新增页面必须登记在 `pages.json` 并纳入编译审计。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 1: API 会话与响应契约
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `utils/config.js`, `utils/session.js`, `utils/api.js`
|
|
||||||
- Create: `utils/genealogy-context.js`, `tests/core-flow-contract.ps1`
|
|
||||||
|
|
||||||
- [x] 写失败测试,要求存在显式模式、登录方法、`data` 解包、token 与当前家谱 ID 持久化。
|
|
||||||
- [x] 运行 PowerShell 合同测试并确认因方法缺失失败。
|
|
||||||
- [x] 实现最小 API 适配层和上下文模块。
|
|
||||||
- [x] 重跑合同测试,确认通过。
|
|
||||||
|
|
||||||
### Task 2: 认证与我的家谱
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `pages/auth/login.vue`, `pages/genealogy/index.vue`, `components/GenealogyCard.vue`
|
|
||||||
- Test: `tests/core-flow-contract.ps1`
|
|
||||||
|
|
||||||
- [x] 写失败测试,要求登录页调用 API、保存 token,家谱卡片和列表使用真实 ID。
|
|
||||||
- [x] 运行测试确认失败。
|
|
||||||
- [x] 实现密码登录、错误状态与列表加载状态。
|
|
||||||
- [ ] 重跑测试并在 Mock 模式下手动走通登录至列表。
|
|
||||||
|
|
||||||
### Task 3: 当前家谱与首代录入
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `pages.json`, `pages/genealogy/create.vue`, `pages/genealogy/detail.vue`, `pages/tree/index.vue`, `pages/member/detail.vue`
|
|
||||||
- Create: `pages/lineage/first-person.vue`
|
|
||||||
- Test: `tests/core-flow-contract.ps1`
|
|
||||||
|
|
||||||
- [x] 写失败测试,要求新页面、路由参数和 API 方法存在,且不得写死 `1001`/`3`。
|
|
||||||
- [x] 运行测试确认失败。
|
|
||||||
- [x] 实现创建家谱后录入首代、按路由 ID 加载详情/树/成员。
|
|
||||||
- [x] 重跑合同测试与 HBuilderX Sass/语言诊断。
|
|
||||||
|
|
||||||
### Task 4: 加入审核与通知的上下文修复
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `pages/genealogy/search.vue`, `pages/genealogy/applications.vue`, `pages/notification/index.vue`, `utils/api.js`
|
|
||||||
- Test: `tests/core-flow-contract.ps1`
|
|
||||||
|
|
||||||
- [x] 写失败测试,要求申请和审核使用当前家谱 ID,通知读取操作有对应 API 方法。
|
|
||||||
- [x] 运行测试确认失败。
|
|
||||||
- [x] 实现上下文传递与明确的本地状态更新。
|
|
||||||
- [x] 重跑所有项目测试及 HBuilderX 实际样式/模块请求验证。
|
|
||||||
@@ -1,230 +0,0 @@
|
|||||||
# G01 信息层级与谱印校正 Implementation Plan
|
|
||||||
|
|
||||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
||||||
|
|
||||||
**Goal:** 让“我的家谱”内容区按已确认的 v5 参考稿呈现谱印、元信息、快捷入口、列表和新建按钮的视觉层级。
|
|
||||||
|
|
||||||
**Architecture:** 视觉资产只放在 `static/assets/icons/`,页面和卡片组件只引用资产与渲染动态数据。`pages/genealogy/index.vue` 负责当前家谱、快捷入口和新建按钮;`components/GenealogyCard.vue` 负责每条列表的谱印、两层元信息和右侧操作列。
|
|
||||||
|
|
||||||
**Tech Stack:** uni-app Vue 3、SCSS、项目本地 RGBA PNG、PowerShell 契约审计。
|
|
||||||
|
|
||||||
## Global Constraints
|
|
||||||
|
|
||||||
- 视觉基准:`docs/design/screens/G01-我的家谱-紧凑版设计稿-v5-Tabbar安全区.png`。
|
|
||||||
- Android only;继续使用 rpx、flex、小屏 `max-width: 340px` 规则,不使用 `clip-path` 或 CSS 绘图。
|
|
||||||
- 所有新增可见图标、谱印框、云纹均为项目内透明 PNG;动态姓名/数字使用 Vue 文本,不嵌死在图片中。
|
|
||||||
- 不改接口、路由、页头、背景、Tabbar 或其他页面;仅为现有展示用 `data/mock.js` 家谱条目补充 `updatedAt` 日期字段。
|
|
||||||
- 工作目录不是 Git 仓库,本轮不初始化仓库、不创建提交。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 1: 谱印与元信息 PNG 资产
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Create: `static/assets/icons/common/location-v1.png`
|
|
||||||
- Create: `static/assets/icons/common/member-meta-v1.png`
|
|
||||||
- Create: `static/assets/icons/common/admin-v1.png`
|
|
||||||
- Create: `static/assets/icons/genealogy/seal-current-frame-v1.png`
|
|
||||||
- Create: `static/assets/icons/genealogy/seal-row-frame-v1.png`
|
|
||||||
- Create: `static/assets/icons/action/create-cloud-v1.png`
|
|
||||||
- Modify: `tests/g01-visual-contract.ps1`
|
|
||||||
|
|
||||||
**Interfaces:**
|
|
||||||
- Produces 96×96 RGBA metadata icons, separate transparent current/list seal-frame PNGs, and a transparent cloud ornament consumed by Tasks 2–3.
|
|
||||||
- Each asset corner alpha must be 0; the current seal frame must have taller visible bounds than the row seal frame.
|
|
||||||
|
|
||||||
- [x] **Step 1: Write the failing asset contract**
|
|
||||||
|
|
||||||
Add the six paths to `$requiredAssets`, then add these assertions to `tests/g01-visual-contract.ps1`:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
foreach ($asset in @('location-v1.png', 'member-meta-v1.png', 'admin-v1.png', 'seal-current-frame-v1.png', 'seal-row-frame-v1.png', 'create-cloud-v1.png')) {
|
|
||||||
if ($page -notmatch [regex]::Escape($asset) -and $card -notmatch [regex]::Escape($asset)) {
|
|
||||||
throw "G-01 does not consume $asset"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [x] **Step 2: Run the contract and verify it fails**
|
|
||||||
|
|
||||||
Run: `powershell -ExecutionPolicy Bypass -File tests\g01-visual-contract.ps1`
|
|
||||||
Expected: `FAIL` because the asset files and component references do not exist.
|
|
||||||
|
|
||||||
- [x] **Step 3: Generate and inspect the assets**
|
|
||||||
|
|
||||||
Generate one asset at a time on a flat chroma-key background, remove the chroma key to alpha, and inspect the selected PNG before adding it. Use this visual language: antique-gold single/double outline, Chinese genealogy motif, no text, no shadow, transparent canvas. For the seal frames, generate only the red-and-gold frame; do not include surname or “家谱” text.
|
|
||||||
|
|
||||||
- [x] **Step 4: Verify transparent corners**
|
|
||||||
|
|
||||||
Run a PowerShell `System.Drawing.Bitmap` alpha audit that checks the four canvas corners of every new PNG are 0.
|
|
||||||
|
|
||||||
Expected: all six assets report `PASS ... transparent corners`.
|
|
||||||
|
|
||||||
### Task 2: 当前家谱题签、快捷入口与新建按钮
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `pages/genealogy/index.vue`
|
|
||||||
- Modify: `tests/g01-visual-contract.ps1`
|
|
||||||
|
|
||||||
**Interfaces:**
|
|
||||||
- Consumes the Task 1 metadata icons, `seal-current-frame-v1.png`, and `create-cloud-v1.png`.
|
|
||||||
- Keeps `currentGenealogy` as the data owner; does not add API calls.
|
|
||||||
|
|
||||||
- [x] **Step 1: Write the failing page-layout contract**
|
|
||||||
|
|
||||||
Require the current title block to render three `.current-meta-item` entries and the current seal to include a `.current-seal-frame` image. Require the shortcut display size and create cloud image:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
foreach ($token in @('current-meta-item', 'current-seal-frame', 'location-v1.png', 'member-meta-v1.png', 'admin-v1.png', 'create-cloud-v1.png')) {
|
|
||||||
if ($page -notmatch $token) { throw "G-01 current panel is missing $token" }
|
|
||||||
}
|
|
||||||
if ($page -notmatch 'width:\s*82rpx') { throw 'G-01 shortcut icons remain below the reference visual size.' }
|
|
||||||
```
|
|
||||||
|
|
||||||
- [x] **Step 2: Run the contract and verify it fails**
|
|
||||||
|
|
||||||
Run: `powershell -ExecutionPolicy Bypass -File tests\g01-visual-contract.ps1`
|
|
||||||
Expected: `FAIL` because current content is still a pure text metadata row and has no seal/cloud image references.
|
|
||||||
|
|
||||||
- [x] **Step 3: Implement the minimum current-panel markup**
|
|
||||||
|
|
||||||
Replace the pure red `current-seal` block with an image layer and dynamic text overlay:
|
|
||||||
|
|
||||||
```vue
|
|
||||||
<view class="current-seal">
|
|
||||||
<image class="current-seal-frame" src="/static/assets/icons/genealogy/seal-current-frame-v1.png" mode="scaleToFill" />
|
|
||||||
<text class="current-seal-title">家谱</text>
|
|
||||||
</view>
|
|
||||||
```
|
|
||||||
|
|
||||||
Render three icon/text metadata groups using the created `location-v1.png`, `member-meta-v1.png`, and `admin-v1.png`; retain `currentGenealogy.location` and `currentGenealogy.memberCount` as the text values.
|
|
||||||
|
|
||||||
- [x] **Step 4: Implement the spacing-only CSS changes**
|
|
||||||
|
|
||||||
Set shortcut image box to `82rpx`; increase label size and give the shortcut grid the reference-like vertical space. Use flex wrapping for current metadata and keep the existing `@media screen and (max-width: 340px)` behavior so no item overlaps.
|
|
||||||
|
|
||||||
- [x] **Step 5: Implement the create-button ornament**
|
|
||||||
|
|
||||||
Place two instances of `create-cloud-v1.png` inside `.create-action`; mirror only the right PNG with `transform: scaleX(-1)`. Keep the existing add PNG and navigation handler.
|
|
||||||
|
|
||||||
- [x] **Step 6: Run the contract and verify it passes**
|
|
||||||
|
|
||||||
Run: `powershell -ExecutionPolicy Bypass -File tests\g01-visual-contract.ps1`
|
|
||||||
Expected: `PASS G-01 visual contract`.
|
|
||||||
|
|
||||||
### Task 3: 家谱列表谱印与信息层级
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `components/GenealogyCard.vue`
|
|
||||||
- Modify: `data/mock.js`
|
|
||||||
- Modify: `tests/g01-visual-contract.ps1`
|
|
||||||
|
|
||||||
**Interfaces:**
|
|
||||||
- Consumes `genealogy` (`surname`, `name`, `location`, `memberCount`, `updatedAt`), `role`, and the Task 1 row seal asset.
|
|
||||||
- Emits the unchanged `select` event.
|
|
||||||
|
|
||||||
- [x] **Step 1: Write the failing card-layout contract**
|
|
||||||
|
|
||||||
Require `seal-row-frame-v1.png` and an explicit `.card-detail-row`:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
foreach ($token in @('seal-row-frame-v1.png', 'card-detail-row', 'card-updated')) {
|
|
||||||
if ($card -notmatch $token) { throw "G-01 list card is missing $token" }
|
|
||||||
}
|
|
||||||
if ($card -match 'background:\s*#b93b2e') { throw 'G-01 list seal is still a plain CSS red block.' }
|
|
||||||
```
|
|
||||||
|
|
||||||
- [x] **Step 2: Run the contract and verify it fails**
|
|
||||||
|
|
||||||
Run: `powershell -ExecutionPolicy Bypass -File tests\g01-visual-contract.ps1`
|
|
||||||
Expected: `FAIL` because the current list card has no frame asset or two-level detail markup.
|
|
||||||
|
|
||||||
- [x] **Step 3: Implement the minimal list-card markup**
|
|
||||||
|
|
||||||
Use a `.surname-seal` image frame with text overlay, then split metadata into title/detail/update rows:
|
|
||||||
|
|
||||||
```vue
|
|
||||||
<view class="card-detail-row">
|
|
||||||
<text class="card-meta">{{ genealogy.location }} · {{ genealogy.memberCount }} 位成员</text>
|
|
||||||
<view class="card-updated"><text>更新于 {{ genealogy.updatedAt }}</text></view>
|
|
||||||
</view>
|
|
||||||
```
|
|
||||||
|
|
||||||
On narrow screens, hide `.card-updated`; preserve the role and chevron column.
|
|
||||||
|
|
||||||
- [x] **Step 3a: Add the visual fixture dates**
|
|
||||||
|
|
||||||
Add `updatedAt: '2024-05-12'` to genealogy `1001` and `updatedAt: '2024-04-28'` to genealogy `1002` in `data/mock.js`. These fields are display-only fixtures and do not change the API contract.
|
|
||||||
|
|
||||||
- [x] **Step 4: Implement CSS sizing and small-screen fallback**
|
|
||||||
|
|
||||||
Increase card name/title prominence, keep row-frame as the only paper frame, set right-side role/chevron to a centered fixed column, and make `.card-updated` disappear at `max-width: 340px` before text can collide.
|
|
||||||
|
|
||||||
- [x] **Step 5: Run the contract and verify it passes**
|
|
||||||
|
|
||||||
Run: `powershell -ExecutionPolicy Bypass -File tests\g01-visual-contract.ps1`
|
|
||||||
Expected: `PASS G-01 visual contract`.
|
|
||||||
|
|
||||||
### Task 4: Documentation and full verification
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `docs/design/G01_我的家谱_设计记录.md`
|
|
||||||
- Modify: `docs/规划.md`
|
|
||||||
|
|
||||||
**Interfaces:**
|
|
||||||
- Records only completed implementation and verification; leaves G01 unchecked until an HBuilderX comparison screenshot is accepted.
|
|
||||||
|
|
||||||
- [x] **Step 1: Record the completed implementation scope**
|
|
||||||
|
|
||||||
Append the new asset paths, the explicit visual reference, and the small-screen fallback to the G01 design record. Mark the implementation subtask complete in `docs/规划.md`; do not mark the full G01 design complete.
|
|
||||||
|
|
||||||
- [x] **Step 2: Run focused verification**
|
|
||||||
|
|
||||||
Run:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
powershell -ExecutionPolicy Bypass -File tests\g01-visual-contract.ps1
|
|
||||||
powershell -ExecutionPolicy Bypass -File tests\compile-audit.ps1
|
|
||||||
powershell -ExecutionPolicy Bypass -File tests\uni-scss-injection.ps1
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: three `PASS` lines and no warnings.
|
|
||||||
|
|
||||||
- [ ] **Step 3: Capture visual evidence in HBuilderX**
|
|
||||||
|
|
||||||
At the same phone viewport as the supplied comparison, capture G01 and compare it side-by-side with the v5 reference. Confirm the current title/meta row, all three seals, four shortcut icon sizes, list row hierarchy, and cloud button ornament. If a mismatch remains, record it rather than marking G01 complete.
|
|
||||||
|
|
||||||
## Plan self-review
|
|
||||||
|
|
||||||
- Spec coverage: Tasks 1–3 implement every asset, layout, small-screen, and visual hierarchy requirement; Task 4 records and verifies it.
|
|
||||||
- No placeholders: all new paths, CSS tokens, component classes, commands, and expected outputs are named.
|
|
||||||
- Consistency: page uses current panel assets; card uses row seal and metadata assets; both are enforced by one G01 visual contract.
|
|
||||||
|
|
||||||
### Task 5: 参考稿字段与列表基线校正
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `data/mock.js`
|
|
||||||
- Modify: `pages/genealogy/index.vue`
|
|
||||||
- Modify: `components/GenealogyCard.vue`
|
|
||||||
- Modify: `tests/g01-visual-contract.ps1`
|
|
||||||
|
|
||||||
**Interfaces:**
|
|
||||||
- Produces reference-matched visual fixture fields without changing any remote API contract.
|
|
||||||
- Keeps the existing navigation and card `select` event unchanged.
|
|
||||||
|
|
||||||
- [x] **Step 1: Write the failing visual-fixture contract**
|
|
||||||
|
|
||||||
Require the four reference names/locations/counts, `.current-info-divider`, a row-direction `.card-side`, and no `calendar-v1.png` use in the list component.
|
|
||||||
|
|
||||||
- [x] **Step 2: Run the contract and verify it fails**
|
|
||||||
|
|
||||||
Run: `powershell -ExecutionPolicy Bypass -File tests\g01-visual-contract.ps1`
|
|
||||||
Expected: `FAIL` because the old demonstration fields, missing current divider, vertical role/arrow, and calendar icon are still present.
|
|
||||||
|
|
||||||
- [x] **Step 3: Apply the smallest source correction**
|
|
||||||
|
|
||||||
Replace only G01 mock fixture fields with the strings in the approved reference. Add the current-panel divider; render fixed vertical “家谱” list seal text; remove the list calendar icon; align `.card-role` and `.card-chevron` horizontally.
|
|
||||||
|
|
||||||
- [x] **Step 4: Run focused verification**
|
|
||||||
|
|
||||||
Run the G01 visual contract, PNG alpha audit, compile audit, SCSS injection audit, and Vue 3 entry audit. Keep Task 4 visual capture unchecked until the user accepts a new HBuilderX screenshot.
|
|
||||||
@@ -1,221 +0,0 @@
|
|||||||
# G-01 Visual Shell Implementation Plan
|
|
||||||
|
|
||||||
> For agentic workers: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox syntax for tracking.
|
|
||||||
|
|
||||||
**Goal:** Rebuild the Android “我的家谱” G-01 page as the approved 家祠卷轴 design with verified local PNG assets and stable mock presentation data.
|
|
||||||
|
|
||||||
**Architecture:** pages/genealogy/index.vue owns local visual states only. It composes a heritage header, paper-slip list items, and safe bottom navigation; it does not make remote API calls during this visual-construction phase.
|
|
||||||
|
|
||||||
**Tech Stack:** uni-app, Vue 3 script setup, SCSS/rpx, local RGBA PNG/JPG assets, PowerShell checks.
|
|
||||||
|
|
||||||
## Global Constraints
|
|
||||||
|
|
||||||
- Android only; verify 360 × 800 and 320 × 568 without overlap.
|
|
||||||
- No API, authentication, packaging, dependency, or unrelated-flow changes.
|
|
||||||
- Every functional icon is a local RGBA PNG under static/assets/icons/.
|
|
||||||
- Header uses the confirmed original brand/jiapu-seal-logo.png in the left safe area; title is centered; notification dot renders only for unread messages.
|
|
||||||
- Tabbar is fixed at the bottom, has 64dp content plus Android navigation inset, and keeps the icon and label clear of the lower edge.
|
|
||||||
- Do not mark G-01 design complete until assets, normal/empty/loading states, and small-screen checks have evidence.
|
|
||||||
|
|
||||||
## File Responsibilities
|
|
||||||
|
|
||||||
| File | Responsibility |
|
|
||||||
| --- | --- |
|
|
||||||
| static/assets/icons/genealogy/*-v2.png | Four compact antique-gold G-01 shortcut icons. |
|
|
||||||
| static/assets/icons/action/add-v2.png | The compact 新建家谱 icon. |
|
|
||||||
| static/assets/icons/common/chevron-right-v2.png | Compact right-arrow asset for the paper-slip list row. |
|
|
||||||
| static/assets/icons/tab/*-v4.png | Default and active 24dp tab icon pairs. |
|
|
||||||
| components/PageHeader.vue | Heritage header, original Logo, notification PNG and optional dot. |
|
|
||||||
| components/AppTabbar.vue | Fixed Android-safe three-tab navigation. |
|
|
||||||
| components/GenealogyCard.vue | Compact paper-slip genealogy list row. |
|
|
||||||
| pages/genealogy/index.vue | G-01 content, local state and route actions. |
|
|
||||||
| tests/g01-visual-contract.ps1 | Asset and static page-contract checks. |
|
|
||||||
|
|
||||||
## Task 1: Produce and register G-01 visual assets
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Create: static/assets/icons/genealogy/tree-v2.png
|
|
||||||
- Create: static/assets/icons/genealogy/members-v2.png
|
|
||||||
- Create: static/assets/icons/genealogy/generation-poem-v2.png
|
|
||||||
- Create: static/assets/icons/genealogy/application-v2.png
|
|
||||||
- Create: static/assets/icons/action/add-v2.png
|
|
||||||
- Create: static/assets/icons/common/chevron-right-v2.png
|
|
||||||
- Create: static/assets/icons/tab/{genealogy,family,profile}-v4.png
|
|
||||||
- Create: static/assets/icons/tab/{genealogy,family,profile}-active-v4.png
|
|
||||||
- Create: tests/g01-visual-contract.ps1
|
|
||||||
- Modify: docs/design/G01_我的家谱_设计记录.md
|
|
||||||
|
|
||||||
**Interfaces:**
|
|
||||||
- Consumes: D1 icon rules and the G-01 asset table.
|
|
||||||
- Produces: the listed asset paths; no legacy static/icons/tab-*.png consumer remains.
|
|
||||||
|
|
||||||
- [x] **Step 1: Write the failing asset contract**
|
|
||||||
|
|
||||||
$root = Split-Path -Parent $PSScriptRoot
|
|
||||||
$requiredAssets = @(
|
|
||||||
'static/assets/icons/brand/jiapu-seal-logo.png',
|
|
||||||
'static/assets/icons/common/notice-v3.png',
|
|
||||||
'static/assets/icons/genealogy/tree-v2.png',
|
|
||||||
'static/assets/icons/genealogy/members-v2.png',
|
|
||||||
'static/assets/icons/genealogy/generation-poem-v2.png',
|
|
||||||
'static/assets/icons/genealogy/application-v2.png',
|
|
||||||
'static/assets/icons/action/add-v2.png',
|
|
||||||
'static/assets/icons/common/chevron-right-v2.png',
|
|
||||||
'static/assets/icons/tab/genealogy-v4.png',
|
|
||||||
'static/assets/icons/tab/genealogy-active-v4.png',
|
|
||||||
'static/assets/icons/tab/family-v4.png',
|
|
||||||
'static/assets/icons/tab/family-active-v4.png',
|
|
||||||
'static/assets/icons/tab/profile-v4.png',
|
|
||||||
'static/assets/icons/tab/profile-active-v4.png'
|
|
||||||
)
|
|
||||||
foreach ($asset in $requiredAssets) {
|
|
||||||
if (-not (Test-Path -LiteralPath (Join-Path $root $asset))) {
|
|
||||||
throw "Missing G-01 visual asset: $asset"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Write-Output 'PASS G-01 visual contract'
|
|
||||||
|
|
||||||
- [x] **Step 2: Run the contract and confirm it fails**
|
|
||||||
|
|
||||||
Run: powershell -ExecutionPolicy Bypass -File C:\Users\Rain\Desktop\job\jiapuapp\tests\g01-visual-contract.ps1
|
|
||||||
|
|
||||||
Expected: an error that names tree-v2.png as missing.
|
|
||||||
|
|
||||||
- [x] **Step 3: Generate and validate every icon**
|
|
||||||
|
|
||||||
Generate each asset separately as a tight 96 × 96px transparent PNG. Shortcut and add icons use antique-gold #B58A4B with the approved restrained double-line style. Tab pairs use the same 24dp silhouette in default ink #342A24 and selected cinnabar #A33B2B; no text, clouds, leaves, shadows, or excess transparent padding.
|
|
||||||
|
|
||||||
- [x] **Step 4: Inspect and register accepted assets**
|
|
||||||
|
|
||||||
Inspect Tabs at 46rpx and shortcuts at 48rpx. Reject opaque corners, excess canvas padding, unrecognizable semantics, and any default/selected silhouette mismatch. Update the G-01 asset table with actual path, 96 × 96px, transparency result, and display-size result. Do not tick page-completion boxes yet.
|
|
||||||
|
|
||||||
- [x] **Step 5: Re-run the asset contract**
|
|
||||||
|
|
||||||
Expected: PASS G-01 visual contract.
|
|
||||||
|
|
||||||
## Task 2: Rebuild header and Android-safe Tabbar
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: components/PageHeader.vue
|
|
||||||
- Modify: components/AppTabbar.vue
|
|
||||||
- Modify: tests/g01-visual-contract.ps1
|
|
||||||
|
|
||||||
**Interfaces:**
|
|
||||||
- Produces: PageHeader title=我的家谱 with unreadCount, and AppTabbar active=genealogy.
|
|
||||||
|
|
||||||
- [x] **Step 1: Extend the failing static contract**
|
|
||||||
|
|
||||||
$header = Get-Content -Raw -Encoding UTF8 (Join-Path $root 'components/PageHeader.vue')
|
|
||||||
$tabbar = Get-Content -Raw -Encoding UTF8 (Join-Path $root 'components/AppTabbar.vue')
|
|
||||||
if ($header -notmatch 'jiapu-seal-logo\.png') { throw 'Header does not use the approved brand PNG.' }
|
|
||||||
if ($header -notmatch 'notice-v3\.png') { throw 'Header does not use the approved notice PNG.' }
|
|
||||||
if ($header -notmatch 'unreadCount\s*>\s*0') { throw 'Header badge is not conditional on unread count.' }
|
|
||||||
if ($tabbar -match '/static/icons/tab-') { throw 'Tabbar still references rejected legacy tab assets.' }
|
|
||||||
if ($tabbar -notmatch 'safe-area-inset-bottom') { throw 'Tabbar has no Android safe-area padding.' }
|
|
||||||
if ($tabbar -notmatch 'font-size:\s*24rpx') { throw 'Tabbar label size is below the approved G-01 size.' }
|
|
||||||
|
|
||||||
- [x] **Step 2: Confirm the contract fails before components change**
|
|
||||||
|
|
||||||
Run the focused contract.
|
|
||||||
|
|
||||||
Expected: failure describing the legacy header or Tabbar implementation.
|
|
||||||
|
|
||||||
- [x] **Step 3: Implement the header**
|
|
||||||
|
|
||||||
Use image-only controls: an 88rpx Logo touch area, centered title, and equal-width 88rpx notice touch area using notice-v3.png. Add v-if unreadCount > 0 for the cinnabar dot. Use the header line-art PNG over a cinnabar base. Do not render text labels for Logo or notification action.
|
|
||||||
|
|
||||||
- [x] **Step 4: Implement the Tabbar**
|
|
||||||
|
|
||||||
Map every item to its v4 default/active PNG paths and choose by active === item.key. Keep a paper-white fixed bar, visible content height 128rpx, padding-bottom env(safe-area-inset-bottom), icon 48rpx, label 24rpx, and each touch target at least 96rpx. Do not use gap.
|
|
||||||
|
|
||||||
- [x] **Step 5: Re-run the contract**
|
|
||||||
|
|
||||||
Expected: PASS G-01 visual contract.
|
|
||||||
|
|
||||||
## Task 3: Rebuild G-01 content and presentation states
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: components/GenealogyCard.vue
|
|
||||||
- Modify: pages/genealogy/index.vue
|
|
||||||
- Modify: tests/g01-visual-contract.ps1
|
|
||||||
|
|
||||||
**Interfaces:**
|
|
||||||
- Consumes: genealogies and notifications from @/data/mock.js.
|
|
||||||
- Produces: isLoading, hasGenealogies, createdGenealogies, and joinedGenealogies visual branches.
|
|
||||||
|
|
||||||
- [x] **Step 1: Add the failing local-state contract**
|
|
||||||
|
|
||||||
$page = Get-Content -Raw -Encoding UTF8 (Join-Path $root 'pages/genealogy/index.vue')
|
|
||||||
if ($page -match "from '@/utils/api\.js'") { throw 'G-01 visual phase must not call the remote API.' }
|
|
||||||
foreach ($state in @('isLoading', 'hasGenealogies', 'createdGenealogies', 'joinedGenealogies')) {
|
|
||||||
if ($page -notmatch $state) { throw "G-01 is missing presentation state: $state" }
|
|
||||||
}
|
|
||||||
foreach ($asset in @('tree-v2.png', 'members-v2.png', 'generation-poem-v2.png', 'application-v2.png', 'add-v2.png')) {
|
|
||||||
if ($page -notmatch [regex]::Escape($asset)) { throw "G-01 does not consume $asset" }
|
|
||||||
}
|
|
||||||
|
|
||||||
- [x] **Step 2: Confirm the contract fails on the legacy page**
|
|
||||||
|
|
||||||
Expected: failure for the API import or missing visual state.
|
|
||||||
|
|
||||||
- [x] **Step 3: Implement the page state owner**
|
|
||||||
|
|
||||||
Remove appApi, genealogyContext, and the mounted API call. Import genealogies and notifications from @/data/mock.js, then define:
|
|
||||||
|
|
||||||
const isLoading = ref(false)
|
|
||||||
const list = ref(genealogies)
|
|
||||||
const hasGenealogies = computed(() => list.value.length > 0)
|
|
||||||
const createdGenealogies = computed(() => list.value.filter((item, index) => index === 0))
|
|
||||||
const joinedGenealogies = computed(() => list.value.filter((item, index) => index > 0))
|
|
||||||
|
|
||||||
Retain only route navigation: tree /pages/tree/index, members /pages/genealogy/detail, generation poem /pages/content/list?type=poem, applications /pages/genealogy/applications, create /pages/genealogy/create, search /pages/genealogy/search, and notifications /pages/notification/index.
|
|
||||||
|
|
||||||
- [x] **Step 4: Implement approved compact layout**
|
|
||||||
|
|
||||||
Compose heritage header, current-genealogy paper slip, four equal shortcut entries with v2 assets, separate 我创建的 / 我加入的 groups, compact paper-slip rows, and the add button. Implement a distinct loading state and an empty paper/scroll state with 新建家谱 and 搜索并申请加入. Apply paper texture to the page and mountain/bamboo only to the lower content region. Add bottom content padding that clears the fixed Tabbar.
|
|
||||||
|
|
||||||
- [x] **Step 5: Simplify GenealogyCard.vue**
|
|
||||||
|
|
||||||
Use a seal-style surname block, title, short location/member metadata, identity label, and common/chevron-right-v2.png. Remove the legacy navy cover, motto, and active-member count. Ensure long copy uses min-width: 0, overflow: hidden, and text-overflow: ellipsis.
|
|
||||||
|
|
||||||
- [x] **Step 6: Re-run the focused contract**
|
|
||||||
|
|
||||||
Expected: PASS G-01 visual contract.
|
|
||||||
|
|
||||||
## Task 4: Verify and update the project record
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: docs/design/G01_我的家谱_设计记录.md
|
|
||||||
- Modify: docs/规划.md
|
|
||||||
- Test: tests/g01-visual-contract.ps1
|
|
||||||
- Test: tests/compile-audit.ps1
|
|
||||||
- Test: tests/uni-scss-injection.ps1
|
|
||||||
|
|
||||||
- [x] **Step 1: Run the automated checks**
|
|
||||||
|
|
||||||
Run:
|
|
||||||
|
|
||||||
powershell -ExecutionPolicy Bypass -File C:\Users\Rain\Desktop\job\jiapuapp\tests\g01-visual-contract.ps1
|
|
||||||
powershell -ExecutionPolicy Bypass -File C:\Users\Rain\Desktop\job\jiapuapp\tests\compile-audit.ps1
|
|
||||||
powershell -ExecutionPolicy Bypass -File C:\Users\Rain\Desktop\job\jiapuapp\tests\uni-scss-injection.ps1
|
|
||||||
|
|
||||||
Expected: each command prints its PASS result.
|
|
||||||
|
|
||||||
- [ ] **Step 2: Verify at Android reference sizes**
|
|
||||||
|
|
||||||
Inspect 360 × 800 and 320 × 568. Confirm no overlap among Logo, title, and bell; notice dot only appears with unread entries; shortcut labels remain one line; list text truncates instead of overlaps; create action clears the Tabbar; all three Tab labels remain fully visible above system navigation.
|
|
||||||
|
|
||||||
- [ ] **Step 3: Update only verified design state**
|
|
||||||
|
|
||||||
In the G-01 record, tick only conditions backed by Tasks 1–2 and the two viewport checks. In the overall planning document, leave G-01 unchecked if even one state, asset, or viewport condition remains incomplete; otherwise record visual shell implemented and verified.
|
|
||||||
|
|
||||||
- [ ] **Step 4: Commit only when Git becomes available**
|
|
||||||
|
|
||||||
If a Git worktree exists, commit changed visual-shell files with message: feat: build G01 genealogy visual shell. If Git remains unavailable, report that no commit was created and do not initialize a repository.
|
|
||||||
|
|
||||||
## Plan Self-Review
|
|
||||||
|
|
||||||
- Task 1 covers local assets and transparency checks.
|
|
||||||
- Task 2 covers the Logo, conditional notification dot, and safe Tabbar.
|
|
||||||
- Task 3 covers G-01 normal, loading, and empty states plus approved compact layout.
|
|
||||||
- Task 4 requires automated and small-screen verification before any completion status changes.
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
# 真实接口核心流程设计
|
|
||||||
|
|
||||||
## 目标
|
|
||||||
|
|
||||||
把现有静态界面升级为可在 Mock 和远端接口之间明确切换的核心流程:登录后进入我的家谱,按当前家谱 ID 查看详情、世系树和成员,并完成创建家谱后录入首代人物。
|
|
||||||
|
|
||||||
## 方案选择
|
|
||||||
|
|
||||||
- 采用显式 `mode: 'mock' | 'remote'` 配置;远端请求失败时显示错误,不自动切回 Mock。
|
|
||||||
- 认证支持密码登录和短信登录。密码登录用于已有测试账号;短信入口按 OpenAPI 请求验证码和登录。
|
|
||||||
- API 适配层统一解包 OpenAPI 的 `{ code, msg, data }` 响应,统一保存/清除 token,并在 401/403 时提示重新登录。
|
|
||||||
- 当前家谱 ID 以页面参数为主、持久化上下文为辅。详情、树、成员、审核、家族动态不得再写死 `1001`。
|
|
||||||
|
|
||||||
## 范围
|
|
||||||
|
|
||||||
本阶段完成认证、会话、家谱上下文、创建家谱、录入首代、详情、世系树、成员档案的真实数据链路。
|
|
||||||
|
|
||||||
本阶段不实现谱文、相册、祭祀、VIP、帮助、反馈等内容模块;这些入口继续明确提示“后续开放”。
|
|
||||||
|
|
||||||
## 验收
|
|
||||||
|
|
||||||
1. Mock 模式下可完成:登录、创建家谱、录入首代、进入对应世系树、打开对应成员档案。
|
|
||||||
2. Remote 模式下所有请求携带 `clientid` 与已保存 token,并按接口响应的 `data` 字段消费。
|
|
||||||
3. 远端登录/接口失败时不会跳转成功、不会静默返回 Mock 数据,并能给出可理解提示。
|
|
||||||
4. 页面跳转不再依赖硬编码家谱 ID 或成员 ID。
|
|
||||||
|
|
||||||
## 已知外部限制
|
|
||||||
|
|
||||||
当前远端密码登录探测返回 HTTP 500,前端会保留远端模式和错误提示,但无法在服务端恢复前完成真实账号的端到端验收。
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
# G01 参考稿信息层级校正设计
|
|
||||||
|
|
||||||
## 已确认的目标
|
|
||||||
|
|
||||||
- 以 `docs/design/screens/G01-我的家谱-紧凑版设计稿-v5-Tabbar安全区.png` 为唯一视觉基准;不再以当前浏览器页为准缩放设计。
|
|
||||||
- 本轮只校正用户箭头指出的内容区:当前家谱题签的信息层级、四个快捷入口、分隔线之后的两条列表和“新建家谱”按钮。
|
|
||||||
- 保留已确认的朱砂页头、宣纸底纹、淡墨山水和底部 Tabbar;不接入接口、不改路由;仅为现有展示用 `data/mock.js` 家谱条目补充参考稿需要的 `updatedAt` 日期字段。
|
|
||||||
- 预览阶段的展示字段必须与参考稿一致:当前家谱为“汤氏家谱 / 河南·洛阳 / 158 位成员 / 管理员”,已加入家谱为“汤氏宗谱 / 山东·济宁 / 286 位成员 / 成员”。视觉验收期间不得保留四川、达州、刘氏等旧演示字段。
|
|
||||||
|
|
||||||
## 方案比较与选择
|
|
||||||
|
|
||||||
1. 仅增大现有 CSS 字号和图标容器:改动少,但仍缺地点/成员/管理员图标和更新时间层级,不能还原参考稿。
|
|
||||||
2. 同时压缩所有内容区:会牺牲旧安卓小屏可读性,也无法解决信息结构错误。
|
|
||||||
3. **按参考稿重建四个内容区的信息栅格**:补齐独立 PNG 元信息图标,放大可见图标和标题字号,使用两行列表元信息与右侧角色列。选此方案。
|
|
||||||
|
|
||||||
## 页面规格
|
|
||||||
|
|
||||||
### 当前家谱题签
|
|
||||||
|
|
||||||
- 保持纸签框资产和透明四角;名称为最高层级,使用楷体回退链。
|
|
||||||
- 左侧“家谱”不是普通红色圆角块:改为独立的竖式朱砂谱印框 PNG,包含参考稿的双层金白描边与红印底;谱印固定显示“家谱”,家谱名称和数值仍由页面数据渲染,不嵌死在图片中。
|
|
||||||
- 当前家谱谱印比列表谱印更高;两者分别使用对应尺寸的 PNG 框,保持参考稿的层级,不能共用一个拉伸框。
|
|
||||||
- 元信息改为三组:地点、成员数、管理员;每组由 24dp 的透明 PNG 图标与文字构成,不能再只用文字串联。
|
|
||||||
- 三组在可用宽度内均匀排列;320px 小屏可换行但不重叠。
|
|
||||||
- 名称与三组元信息之间保留参考稿中的细金线分隔;在 430px 验收宽度三组必须同一行。
|
|
||||||
|
|
||||||
### 快捷入口与分隔线
|
|
||||||
|
|
||||||
- 快捷图标的显示框放大,使实际图形接近参考稿的视觉面积;标签同步提升,四项保持等宽。
|
|
||||||
- 快捷区与金线云纹分隔线保留明确留白,分区标题不得贴线。
|
|
||||||
|
|
||||||
### 家谱列表
|
|
||||||
|
|
||||||
- 每条维持纸签框和透明四角;标题提升为主要信息。
|
|
||||||
- 左侧使用较小的竖式朱砂谱印框 PNG;已加入家谱可保持低饱和底色,但边框、比例与“我创建的”谱印一致。
|
|
||||||
- 次行固定为“地点 + 成员数”;右侧固定为“角色 + 箭头”,并在宽屏下补充“更新于日期”。
|
|
||||||
- 小屏降级时优先保留标题、地点、成员数和角色;更新时间可隐藏,禁止文字压到箭头。
|
|
||||||
- 角色文字与右箭头使用同一水平基线;日期是文本信息,不增加参考稿中没有的日历图标。
|
|
||||||
- 列表谱印固定显示竖排“家谱”,不显示动态姓氏;已加入列表使用低饱和谱印状态。
|
|
||||||
|
|
||||||
### 新建按钮
|
|
||||||
|
|
||||||
- 保留独立 PNG 加号;放大文字和图标实际显示尺寸。
|
|
||||||
- 补齐参考稿的左右云纹装饰 PNG,不用 CSS 或文字模拟装饰。
|
|
||||||
|
|
||||||
## 资产与兼容性
|
|
||||||
|
|
||||||
- 新增地点、成员、管理员、更新时间和按钮云纹均为项目内 RGBA PNG;图标画布为 96px,并检查四角透明。
|
|
||||||
- 不用 `clip-path`、蒙版或其他旧 Android WebView 风险较高的裁切方案。
|
|
||||||
- 所有布局继续使用 rpx、弹性布局、单行省略和 `max-width: 340px` 小屏规则。
|
|
||||||
|
|
||||||
## 验收与验证
|
|
||||||
|
|
||||||
1. 自动契约先失败:要求新元信息图标、两行列表结构、放大的快捷图标和新建按钮云纹存在;旧的纯文字元信息实现必须不再通过。
|
|
||||||
2. 完成最小代码与 PNG 资产改动后,运行 G01 契约、编译审计、SCSS 注入审计和 PNG 透明角检查。
|
|
||||||
3. 使用 HBuilderX 在同一设备尺寸重新截图,与参考稿并排比对:重点确认箭头所指的字号、间距、图标面积、列表行高与按钮装饰。
|
|
||||||
|
|
||||||
## 自检
|
|
||||||
|
|
||||||
- 无占位项、无接口或路由扩展。
|
|
||||||
- 唯一视觉来源是已确认的 G01 v5 参考稿;本轮没有重设颜色、背景或页面功能。
|
|
||||||
- 若 320px 宽度装不下,按本文件定义隐藏更新时间,而不是压缩全部文字。
|
|
||||||
@@ -1,105 +0,0 @@
|
|||||||
# 家谱 APP 交接记录
|
|
||||||
|
|
||||||
> 更新时间:2026-07-13
|
|
||||||
> 用途:更换电脑或更换 GPT 后,先读本文件即可恢复当前工作上下文。
|
|
||||||
> 保密说明:账号、密码、令牌和其他敏感信息不记录在本文件。
|
|
||||||
|
|
||||||
## 1. 新接手者先做什么
|
|
||||||
|
|
||||||
按以下顺序阅读,不要跳过:
|
|
||||||
|
|
||||||
1. 根目录 `AGENTS.md`:项目工作约束,要求先确认依据、最小修改、修改后验证。
|
|
||||||
2. `docs/规划.md`:全项目范围、完成状态与推进顺序。
|
|
||||||
3. `docs/design/D1_安卓视觉规范与页面壳.md`:Android 视觉、兼容性、PNG 资产与背景规则。
|
|
||||||
4. `docs/design/G01_我的家谱_设计记录.md`:G01 已完成的施工与尚未验收项。
|
|
||||||
5. `docs/superpowers/specs/2026-07-12-g01-reference-typography-design.md` 与 `docs/superpowers/plans/2026-07-12-g01-information-hierarchy.md`:G01 的已确认规格与执行记录。
|
|
||||||
|
|
||||||
视觉唯一基准:
|
|
||||||
|
|
||||||
- `docs/design/screens/G01-我的家谱-紧凑版设计稿-v5-Tabbar安全区.png`
|
|
||||||
|
|
||||||
不要把“红色中国风”当作完成标准;G01 需要按此图逐区、逐字段、逐比例比对。
|
|
||||||
|
|
||||||
## 2. 项目与范围
|
|
||||||
|
|
||||||
- 项目:uni-app,目标为 Android,使用 HBuilderX 打包。
|
|
||||||
- 当前阶段:先完成全部页面视觉设计和小屏验收;**暂不恢复接口联调、真实账号联调或 Android 打包**。
|
|
||||||
- 当前正在处理:`G-01 我的家谱`。
|
|
||||||
- 当前不做:iOS、PC 管理端、官网,以及新增业务接口。
|
|
||||||
- 本地展示数据的唯一来源:`data/mock.js`;G01 当前不调用远程 API。
|
|
||||||
- 本目录当前不是 Git 仓库;不要擅自初始化仓库、重置文件或删除既有用户文件。
|
|
||||||
|
|
||||||
## 3. 用户已确认的视觉规则
|
|
||||||
|
|
||||||
- 风格是“家祠卷轴”:朱砂页头、暖宣纸、古金细线、墨褐文字、淡墨山水/竹影;禁止绿色页面背景。
|
|
||||||
- 需要使用的图标、谱印框与云纹必须是项目内的透明 PNG;不能用文字、emoji、CSS 图形或网络图片替代可见图标。
|
|
||||||
- 参照图中的布局、字号、字段、图标可见面积和留白需要尽量一比一还原;不要只做“差不多”的国风页面。
|
|
||||||
- 要兼容老款 Android:用 rpx、Flex、边距与基础圆角;避免 CSS Grid、`flex gap`、`clip-path`、复杂滤镜和大面积动画。
|
|
||||||
- 每次完成一个可验收子项,更新 `docs/规划.md` 和对应设计记录;没有 HBuilderX 同尺寸截图确认时,不能勾选整个页面完成。
|
|
||||||
|
|
||||||
## 4. G01 当前实际状态
|
|
||||||
|
|
||||||
### 已施工并通过静态检查
|
|
||||||
|
|
||||||
- 朱砂顶部、项目 Logo、条件式通知红点、宣纸底纹、淡墨底图、底部 Tabbar。
|
|
||||||
- 当前家谱题签、四个快捷入口、分隔金线、创建/加入列表、新建按钮。
|
|
||||||
- 透明 PNG 资产已接入:快捷入口、Tab、通知、箭头、元信息图标、谱印框和新建云纹。
|
|
||||||
- 当前演示字段已统一为参考稿:
|
|
||||||
- 当前家谱:`汤氏家谱 / 河南·洛阳 / 158 位成员 / 管理员`
|
|
||||||
- 已加入家谱:`汤氏宗谱 / 山东·济宁 / 286 位成员 / 成员`
|
|
||||||
- 当前题签有标题/元信息分隔线;列表右侧角色和箭头应为同一水平基线;日期为“更新于”文字,不显示参考稿中没有的日历图标。
|
|
||||||
|
|
||||||
### 必须继续做的事
|
|
||||||
|
|
||||||
1. 用 HBuilderX 刷新 G01,并使用与参考稿相同的手机预览尺寸截屏。
|
|
||||||
2. 将新截图与 v5 参考稿并排比对,重点检查:
|
|
||||||
- 顶部家谱题签的字体、字段、分隔线和三组元信息是否同一行;
|
|
||||||
- 四个快捷图标的实际可见面积与文字大小;
|
|
||||||
- 两条列表的谱印、标题、地点/成员、日期、右侧角色和箭头基线;
|
|
||||||
- 新建按钮的云纹、文字、底部山水和 Tabbar 安全区。
|
|
||||||
3. 有任何差异时,只改对应 G01 组件并重新截图;不要提前勾选 `G-01`。
|
|
||||||
4. 最终还要按 `320 × 568` 和 `360 × 800` 做小屏检查,确认无重叠、无裁切、无底部遮挡。
|
|
||||||
|
|
||||||
## 5. G01 关键文件
|
|
||||||
|
|
||||||
| 作用 | 文件 |
|
|
||||||
| --- | --- |
|
|
||||||
| G01 页面与当前题签/快捷入口/新建按钮 | `pages/genealogy/index.vue` |
|
|
||||||
| G01 列表项 | `components/GenealogyCard.vue` |
|
|
||||||
| 顶部标题栏/Logo/通知点 | `components/PageHeader.vue` |
|
|
||||||
| 底部导航 | `components/AppTabbar.vue` |
|
|
||||||
| 展示数据 | `data/mock.js` |
|
|
||||||
| 视觉变量 | `styles/tokens.scss`、`styles/global.scss` |
|
|
||||||
| G01 图标 | `static/assets/icons/` |
|
|
||||||
| G01 背景 | `static/assets/backgrounds/` |
|
|
||||||
| G01 契约检查 | `tests/g01-visual-contract.ps1` |
|
|
||||||
| 新 PNG 透明检查 | `tests/g01-asset-alpha-audit.ps1` |
|
|
||||||
|
|
||||||
## 6. 已知技术注意项
|
|
||||||
|
|
||||||
- `tests/g01-visual-contract.ps1` 用 UTF-8 Base64 形式检查中文展示字段。这是为了避免 Windows PowerShell 5.1 将无 BOM UTF-8 脚本中的中文字符串误读为乱码;不要把该段直接改回裸中文字面量。
|
|
||||||
- `calendar-v1.png` 已因为参考稿没有该图标而删除;不要再次接入日期图标。
|
|
||||||
- G01 的背景资产应继续使用 `footer-mountain-bamboo.png`,不要换回实心灰山的 `footer-mountain-bamboo-v2.png`。
|
|
||||||
- 题签透明角已经过检查;若再次出现白角,优先检查题签容器是否重新加上不透明的矩形底色。
|
|
||||||
- 所有新 PNG 在接入前都要检查:RGBA、四角透明、主体没有大量空白、显示尺寸符合 24dp/设计稿。
|
|
||||||
|
|
||||||
## 7. 复核命令
|
|
||||||
|
|
||||||
在项目根目录的 PowerShell 中运行:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
powershell -ExecutionPolicy Bypass -File tests\g01-visual-contract.ps1
|
|
||||||
powershell -ExecutionPolicy Bypass -File tests\g01-asset-alpha-audit.ps1
|
|
||||||
powershell -ExecutionPolicy Bypass -File tests\compile-audit.ps1
|
|
||||||
powershell -ExecutionPolicy Bypass -File tests\uni-scss-injection.ps1
|
|
||||||
powershell -ExecutionPolicy Bypass -File tests\vue3-entry.ps1
|
|
||||||
```
|
|
||||||
|
|
||||||
最近一次结果:五项均为 `PASS`。这只证明代码和资产契约通过,**不替代 HBuilderX 视觉验收**。
|
|
||||||
|
|
||||||
## 8. 换电脑操作
|
|
||||||
|
|
||||||
1. 完整复制整个 `jiapuapp` 项目文件夹,保留 `docs/`、`static/`、`tests/`、`data/` 和所有 Vue/SCSS 文件。
|
|
||||||
2. 新电脑安装 HBuilderX 后,直接“打开目录”选择复制后的项目根目录。
|
|
||||||
3. 将本文件和新的 HBuilderX 截图一起发给新的 GPT,并说明:“先读 `docs/交接记录.md` 与 `AGENTS.md`,继续 G01 的视觉验收,不要改接口。”
|
|
||||||
4. 新 GPT 修改后,先运行第 7 节命令,再由用户在 HBuilderX 提供同尺寸截图复核。
|
|
||||||
@@ -1,207 +0,0 @@
|
|||||||
# 规划
|
|
||||||
|
|
||||||
> 电脑或 GPT 更换时,先阅读:[项目交接记录](交接记录.md)。
|
|
||||||
|
|
||||||
> 状态:执行中
|
|
||||||
> 建立日期:2026-07-11
|
|
||||||
> 唯一适用端:Android `uni-app`(HBuilderX 打包)
|
|
||||||
> 执行原则:**先完成全部页面设计,再开始逐页实现;每完成一项,立即在本文件勾选。**
|
|
||||||
|
|
||||||
## 1. 本轮边界
|
|
||||||
|
|
||||||
- 只做手机 Android 用户端;不做 iOS、H5、PC 管理端、后台管理端、官网。
|
|
||||||
- 范围覆盖思维导图中的 Android 用户端:登录、家谱、家族、消息、我的,以及必要的空白、加载、异常、权限状态。
|
|
||||||
- 现有 uni-app 页面与接口代码保留为临时功能骨架,但**不计入任何页面设计完成项**,也不再决定最终视觉样式。
|
|
||||||
- 在“设计验收”完成前,暂停新增业务接口、真实账号联调和打包工作。
|
|
||||||
- 接口暂未提供的视频、邀请码、多管理员分级等功能:本轮可完成其页面视觉和无数据状态;真实功能接入留到接口阶段。
|
|
||||||
|
|
||||||
## 2. 视觉方向(已确定)
|
|
||||||
|
|
||||||
### 2.1 风格名称
|
|
||||||
|
|
||||||
**家祠卷轴**:喜庆而庄重。以宣纸的温度承载家族记忆,以朱砂表达礼序和重要操作,以古金表达传承;避免网红化、过度卡片化和大面积高饱和红色。
|
|
||||||
|
|
||||||
### 2.2 颜色与材质
|
|
||||||
|
|
||||||
| 用途 | 色彩 | 规则 |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| 页面底色 | 宣纸暖白 `#F6F0E5` | 不使用绿色背景。 |
|
|
||||||
| 主操作 / 选中 | 朱砂 `#A33B2B` | 仅用于主按钮、当前状态、关键提醒。 |
|
|
||||||
| 标题 / 主要文字 | 墨褐 `#342A24` | 不使用偏绿的深色。 |
|
|
||||||
| 细分隔 / 装饰 | 古金 `#B58A4B` | 只做细线、印记、等级,不做大色块。 |
|
|
||||||
| 容器 | 纸白 `#FFF9EE` | 用轻边线或极浅阴影分层。 |
|
|
||||||
| 次级文字 | 暖灰褐 `#7A6958` | 保持正文可读性。 |
|
|
||||||
|
|
||||||
### 2.3 固定组件规则
|
|
||||||
|
|
||||||
- 顶部为简洁的家祠题签式标题栏;可回退页面保留清晰返回区,不能让装饰挤压标题。
|
|
||||||
- 底部导航固定为“家谱 / 家族 / 我的”,通知作为右上角入口,不单独占 Tab。
|
|
||||||
- 家谱树中的人物使用竖式人物牌位;姓名、字辈、关系与在世状态层次清楚,不能用装饰遮挡信息。
|
|
||||||
- 所有功能图标均使用透明背景 PNG,统一由 `static/icons/` 管理;不用网络图标字体,不混用不同画风。
|
|
||||||
- 图片内容优先使用用户上传的真实照片;没有照片时使用统一的纸纹占位图,不使用 AI 人像。
|
|
||||||
|
|
||||||
## 3. Android 老机型与小屏兼容(必须验收)
|
|
||||||
|
|
||||||
### 3.1 尺寸与排版
|
|
||||||
|
|
||||||
- 以 `320 × 568`、`360 × 640` 为关键小屏;`360 × 800`、`412 × 915` 为常规屏进行核对。
|
|
||||||
- 布局使用流式纵向滚动;不以单一固定页面高度承载内容,不让底部按钮压住表单。
|
|
||||||
- 一行中可能出现长姓名、长家谱名、长地址时必须截断或换行,不允许文字重叠。
|
|
||||||
- 关键正文不小于 `24rpx`,主要按钮高度不小于 `88rpx`,所有可点击区域不小于约 `44dp`。
|
|
||||||
- 双列内容在 `320px` 宽度下自动折为单列;人物树、照片墙等横向内容提供明确的横向滚动区域与边缘提示。
|
|
||||||
- 顶部、底部均预留系统状态栏和 Android 三键/手势导航空间;无安全区能力的设备也要有保底内边距。
|
|
||||||
|
|
||||||
### 3.2 性能与技术限制
|
|
||||||
|
|
||||||
- 不使用全屏大尺寸背景图、视频背景、复杂滤镜、毛玻璃和持续动画。
|
|
||||||
- 不依赖 `flex gap`、复杂 CSS Grid 或只在新 WebView 支持的视觉效果;以 Flex、边距和基础圆角为主。
|
|
||||||
- 首屏图标为小尺寸透明 PNG;列表图片按缩略图加载,避免一次性渲染大量高清图。
|
|
||||||
- 家谱树按层级或视区呈现,不在低端机一次渲染无边界的全部成员节点。
|
|
||||||
- 表单、弹窗、图片预览都要可关闭、可返回,并在键盘弹起时保持提交按钮可见。
|
|
||||||
|
|
||||||
### 3.3 兼容验收方式
|
|
||||||
|
|
||||||
- 每个页面设计完成后,先按 `320 × 568` 检查:标题、长文案、表单、底部操作、横向内容五项均不得重叠。
|
|
||||||
- 全部页面实现后,以 Android 真机或模拟器覆盖上述四组尺寸,记录在本文件“最终验收”中。
|
|
||||||
- 这里的“兼容”指页面尺寸、性能和交互可用性;具体最低 Android 系统版本在最终 HBuilderX 打包配置阶段按实际 SDK 再确认。
|
|
||||||
|
|
||||||
## 4. 页面设计的完成标准
|
|
||||||
|
|
||||||
一页只有同时满足以下四项,才能标记为“设计完成”:
|
|
||||||
|
|
||||||
1. 有明确的正常态布局、字体层级、颜色、图标、间距与操作层级。
|
|
||||||
2. 有该页需要的空数据、加载、失败、无权限或审核中状态。
|
|
||||||
3. 标明每个主要点击入口的去向、返回路径和确认操作。
|
|
||||||
4. 已按 `320 × 568` 小屏规则检查,无重叠、无被底部导航遮挡的内容。
|
|
||||||
|
|
||||||
## 5. 全量页面清单与完成状态
|
|
||||||
|
|
||||||
### 5.1 设计基础与通用状态
|
|
||||||
|
|
||||||
- [x] P-00 锁定 Android 端范围、家祠卷轴视觉方向与透明 PNG 图标规则。
|
|
||||||
- [x] P-01 锁定老机型 / 小屏适配原则与验收尺寸。
|
|
||||||
- [x] P-02 设计系统:颜色、字号、间距、圆角、按钮、标签、列表、表单、弹窗、空状态。见 [D1 安卓视觉规范与页面壳](design/D1_安卓视觉规范与页面壳.md)。
|
|
||||||
- [x] P-03 公共页面壳:顶部题签、返回栏、三 Tab、右上角通知入口、安全区与底部操作栏。见 [D1 安卓视觉规范与页面壳](design/D1_安卓视觉规范与页面壳.md)。
|
|
||||||
- [x] P-04 通用状态:首次加载、列表加载、网络失败、空数据、无权限、审核中、删除确认、操作成功提示。见 [D1 安卓视觉规范与页面壳](design/D1_安卓视觉规范与页面壳.md)。
|
|
||||||
- [ ] P-05 生成并验收项目 Logo、导航、通知与公共操作的透明 PNG 图标资产。见 [D1 安卓视觉规范与页面壳](design/D1_安卓视觉规范与页面壳.md)。
|
|
||||||
- [ ] P-06 生成并验收祠堂红头、宣纸纸纹、山水 / 竹影等项目内背景资产。见 [D1 安卓视觉规范与页面壳](design/D1_安卓视觉规范与页面壳.md)。
|
|
||||||
|
|
||||||
### 5.2 账户与首次使用
|
|
||||||
|
|
||||||
- [ ] A-01 启动 / 登录引导页。
|
|
||||||
- [ ] A-02 账号密码登录页。
|
|
||||||
- [ ] A-03 手机验证码登录页。
|
|
||||||
- [ ] A-04 注册页与服务协议确认。
|
|
||||||
- [ ] A-05 忘记密码 / 重设密码页。
|
|
||||||
- [ ] A-06 账号受限、登录失败与未完成注册状态。
|
|
||||||
|
|
||||||
### 5.3 家谱:创建、加入与管理入口
|
|
||||||
|
|
||||||
- [x] G-01 信息层级与谱印施工:元信息图标、谱印框、快捷入口、列表双层信息和新建云纹已实现并通过资产/编译审计;待 HBuilderX 同尺寸截图验收,故完整 G-01 仍未勾选。
|
|
||||||
- [ ] G-01 我的家谱列表(已创建、已加入、搜索入口、通知入口)。
|
|
||||||
- [ ] G-02 无家谱空状态(创建家谱 / 搜索加入)。
|
|
||||||
- [ ] G-03 创建家谱表单(名称、地域、简介、公开范围)。
|
|
||||||
- [ ] G-04 创建成功后的始祖录入页。
|
|
||||||
- [ ] G-05 家谱总览(名称、编号、地域、成员数、管理员、公开状态、快捷入口)。
|
|
||||||
- [ ] G-06 公开家谱搜索与筛选页。
|
|
||||||
- [ ] G-07 家谱搜索结果与家谱简介页。
|
|
||||||
- [ ] G-08 申请加入表单、提交成功与审核中页。
|
|
||||||
- [ ] G-09 我的申请记录页。
|
|
||||||
- [ ] G-10 管理员申请审核列表与审核确认页。
|
|
||||||
- [ ] G-11 家谱设置、公开范围与成员访问说明页。
|
|
||||||
- [ ] G-12 字辈诗列表、详情与管理员维护页。
|
|
||||||
|
|
||||||
### 5.4 世系树、成员与亲属关系
|
|
||||||
|
|
||||||
- [ ] T-01 世系树总览(缩放、横向移动、分支切换、当前人物定位)。
|
|
||||||
- [ ] T-02 世系树小屏提示与无成员 / 数据加载失败状态。
|
|
||||||
- [ ] T-03 成员档案详情(基本资料、代际、关系、照片、人生记录入口)。
|
|
||||||
- [ ] T-04 新增直系亲属页(父母、配偶、子女的不同上下文)。
|
|
||||||
- [ ] T-05 编辑成员资料页。
|
|
||||||
- [ ] T-06 编辑亲属关系页与关系冲突提示。
|
|
||||||
- [ ] T-07 成员目录与搜索页。
|
|
||||||
- [ ] T-08 成员去世、隐私隐藏、无编辑权限等特殊状态。
|
|
||||||
|
|
||||||
### 5.5 家族:动态、谱文、相册与影像
|
|
||||||
|
|
||||||
- [ ] F-01 家族首页 / 家族动态流。
|
|
||||||
- [ ] F-02 发布动态页(文字、图片、可见范围)。
|
|
||||||
- [ ] F-03 动态详情、评论、点赞与删除确认页。
|
|
||||||
- [ ] F-04 谱文分类与文章列表页。
|
|
||||||
- [ ] F-05 谱文详情页。
|
|
||||||
- [ ] F-06 新建 / 编辑谱文页。
|
|
||||||
- [ ] F-07 相册列表页。
|
|
||||||
- [ ] F-08 相册详情与照片墙页。
|
|
||||||
- [ ] F-09 图片预览、上传照片与上传失败状态。
|
|
||||||
- [ ] F-10 视频列表、详情、发布入口与“接口尚未开放”状态。
|
|
||||||
|
|
||||||
### 5.6 家族:人物、礼仪与档案
|
|
||||||
|
|
||||||
- [ ] R-01 人物录列表页。
|
|
||||||
- [ ] R-02 人物录详情与新增 / 编辑页。
|
|
||||||
- [ ] R-03 贺礼列表页。
|
|
||||||
- [ ] R-04 新增贺礼、贺礼详情与删除确认页。
|
|
||||||
- [ ] R-05 礼仪活动列表(婚礼、升学、生日、其他)。
|
|
||||||
- [ ] R-06 礼仪详情页。
|
|
||||||
- [ ] R-07 新建 / 编辑礼仪活动页。
|
|
||||||
- [ ] R-08 成长日志列表、详情与新增 / 编辑页。
|
|
||||||
- [ ] R-09 人生事列表、详情与新增 / 编辑页。
|
|
||||||
- [ ] R-10 备忘录列表、详情与新增 / 编辑页。
|
|
||||||
- [ ] R-11 功德记录列表、详情与新增 / 编辑页。
|
|
||||||
|
|
||||||
### 5.7 消息与个人中心
|
|
||||||
|
|
||||||
- [ ] N-01 消息中心(生日、贺礼、礼仪、应用通知、点赞评论、推广反馈分类)。
|
|
||||||
- [ ] N-02 消息详情、已读状态与空消息状态。
|
|
||||||
- [ ] M-01 我的首页(头像、身份、所属家谱、快捷入口)。
|
|
||||||
- [ ] M-02 个人资料查看 / 编辑页。
|
|
||||||
- [ ] M-03 安全设置页。
|
|
||||||
- [ ] M-04 修改密码页。
|
|
||||||
- [ ] M-05 修改手机号页。
|
|
||||||
- [ ] M-06 帮助中心与搜索页。
|
|
||||||
- [ ] M-07 意见反馈页(文字、图片、联系方式)。
|
|
||||||
- [ ] M-08 应用推广页。
|
|
||||||
- [ ] M-09 VIP 权益、套餐与订单状态页。
|
|
||||||
- [ ] M-10 关于我们、服务协议、隐私政策与退出登录确认页。
|
|
||||||
|
|
||||||
## 6. 设计推进顺序(开发前必须全部完成)
|
|
||||||
|
|
||||||
| 阶段 | 交付内容 | 状态 |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| D1 | P-02 至 P-06:统一设计系统、Android 页面壳、公共图标与背景资产 | 进行中(P-02 至 P-04 已完成;P-05、P-06 资产生成中) |
|
|
||||||
| D2 | A-01 至 A-06:账户与首次使用全套页面 | 未开始 |
|
|
||||||
| D3 | G-01 至 G-12:家谱创建、加入、总览与管理全套页面 | 进行中(G-01 视觉壳已施工,待 HBuilderX 小屏预览验收;其余页面未开始) |
|
|
||||||
| D4 | T-01 至 T-08:世系树、成员、关系编辑全套页面 | 未开始 |
|
|
||||||
| D5 | F-01 至 F-10、R-01 至 R-11:家族内容全套页面 | 未开始 |
|
|
||||||
| D6 | N-01 至 N-02、M-01 至 M-10:消息与个人中心全套页面 | 未开始 |
|
|
||||||
| D7 | 全量小屏复核、视觉一致性复核、页面流程复核 | 未开始 |
|
|
||||||
| D8 | 逐页实现 uni-app、接入接口、Android 打包验证 | 未开始(D7 完成后才可开始) |
|
|
||||||
|
|
||||||
## 7. 页面流转主线(设计时必须保持一致)
|
|
||||||
|
|
||||||
1. 登录 → 我的家谱为空 → 创建家谱 → 录入始祖 → 家谱总览 → 世系树 → 成员档案 / 新增亲属。
|
|
||||||
2. 登录 → 搜索公开家谱 → 查看简介 → 申请加入 → 审核中 → 管理员审核 → 进入家谱总览。
|
|
||||||
3. 家族 Tab → 动态 / 谱文 / 相册 / 人物录 / 礼仪 / 档案 → 详情 → 发布或编辑(取决于权限)。
|
|
||||||
4. 任一页面的通知入口 → 消息中心 → 相应业务详情。
|
|
||||||
5. 我的 → 个人资料 / 安全 / 帮助 / 反馈 / VIP / 关于与协议。
|
|
||||||
|
|
||||||
## 8. 本轮执行纪律
|
|
||||||
|
|
||||||
- 每次只推进一个设计阶段;阶段内的每个页面完成后,更新本文件对应复选框。
|
|
||||||
- 未有高保真设计和小屏检查的页面,不得开始其业务开发。
|
|
||||||
- 已确认的视觉规范是代码还原依据;若实现与设计不一致,以设计为准修正实现。
|
|
||||||
- 参考图是视觉锚点;核心材质、层级、比例、选中效果必须逐项一比一核对,不能只保留“红色中国风”的笼统印象。
|
|
||||||
- 设计稿不得保留无业务意义的横线、破折号或模拟字符;颜色差异必须有可见且明确的业务含义。
|
|
||||||
- 后续每一个页面新增的功能图标,必须同步生成、保存并验收透明 PNG 实体资产;未生成资产的页面不得标记为“设计完成”。
|
|
||||||
- 功能图标必须遵循线上成熟标准的 24dp 语义、留白、触控与选中规则,并导出为透明 PNG;项目可在此基础上保留已确认的古金双线、轻云纹收口等家祠风格。当前从大图裁切的试验资产不得引用,需按单图标标准重新导出后验收。
|
|
||||||
- 大部分按钮、顶部操作和快捷入口图标采用已确认的古金双线、轻云纹收口风格;底部 Tab 使用简洁同色系版本。每一页必须指定并使用已生成的背景资产组合,未完成背景设计的页面不得标记为“设计完成”。
|
|
||||||
- 全页 AI 生成图可作为页面的美术效果锚点,但不能单独作为布局验收依据;页面必须同时按固定 Android 栅格、真实数据、真实 PNG 资产制作。已生成的“我的家谱”效果图保留其朱砂、宣纸、题签、山水和庄重层级,留白、Logo、占位文案与图标标准问题按设计栅格修正后才可计入完成项。
|
|
||||||
- 新增需求先写入本文件,再决定是否纳入本轮,避免在页面开发中临时扩张范围。
|
|
||||||
|
|
||||||
## 9. 最终验收(暂未开始)
|
|
||||||
|
|
||||||
- [ ] 所有 P / A / G / T / F / R / N / M 页面均有设计完成标记。
|
|
||||||
- [ ] 全部设计在 `320 × 568` 下无内容重叠、无遮挡、无不可点操作。
|
|
||||||
- [ ] 所有图标均为透明背景 PNG,且无绿色页面背景。
|
|
||||||
- [ ] 页面风格与“家祠卷轴”设计一致,代码页面与确认设计逐屏比对通过。
|
|
||||||
- [ ] 全量页面设计确认后,才解锁 uni-app 实现、接口联调和 Android 打包。
|
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
# 项目交接说明
|
||||||
|
|
||||||
|
更新日期:2026-09-17
|
||||||
|
|
||||||
|
## 当前状态
|
||||||
|
|
||||||
|
这是一个 uni-app / Vue 3 家谱应用,现有 62 个页面。登录、家谱、成员树、家族内容、人物档案、消息和个人中心均已接入页面路由。
|
||||||
|
|
||||||
|
这次清理移除了此前留下的联调、审计、回归和页面对比记录,也删除了资源清单的两份单元测试。项目保留了运行时检查,当前工作区校验通过。
|
||||||
|
|
||||||
|
## 从哪里开始看
|
||||||
|
|
||||||
|
- `pages.json`:页面注册和页面级配置。
|
||||||
|
- `utils/navigation/routes.js`:业务跳转使用的路由定义。新增或删改页面时,这里要和 `pages.json` 一起改。
|
||||||
|
- `services/api/`:接口调用和接口数据整理。一般一个业务模块对应一组 `*-service.js`、`*-contract.js` 文件。
|
||||||
|
- `services/api/request-client.js`:请求入口,处理登录失效、响应格式和上传请求。
|
||||||
|
- `utils/runtime-config.js`:线上接口地址、客户端标识和租户标识。当前写的是正式环境配置。
|
||||||
|
- `static/assets/`:页面实际使用的图片资源;`design-pipeline/manifests/` 保存这些资源的校验清单。
|
||||||
|
|
||||||
|
## 常用检查
|
||||||
|
|
||||||
|
在根目录运行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm.cmd run check
|
||||||
|
```
|
||||||
|
|
||||||
|
PowerShell 的执行策略可能会拦截 `npm`,所以这里固定使用 `npm.cmd`。该命令目前会检查:
|
||||||
|
|
||||||
|
- 页面文件与路由注册是否匹配;
|
||||||
|
- 几处关键页面和导航恢复逻辑是否被误改;
|
||||||
|
- 静态资源文件是否和资源清单一致。
|
||||||
|
|
||||||
|
2026-09-17 的执行结果:
|
||||||
|
|
||||||
|
```text
|
||||||
|
PROJECT CHECK PASS pages=62 routes=62 sources=182
|
||||||
|
AUDIT REGRESSION CHECK PASS icons=17
|
||||||
|
NAVIGATION RECOVERY CHECK PASS
|
||||||
|
RUNTIME ASSET CHECK PASS manifests=4 assets=102
|
||||||
|
```
|
||||||
|
|
||||||
|
页面交互、上传或端能力相关的修改,仍需要在 HBuilderX 和目标端实际走一遍;这里的检查不能代替真机验证。
|
||||||
|
|
||||||
|
## 这次留下的改动
|
||||||
|
|
||||||
|
- 原 `docs/` 中的 12 份过程记录已删除。
|
||||||
|
- `design-pipeline/tests/` 下的两份测试已删除,`design-pipeline` 的 `check` 现在只做资源清单校验。
|
||||||
|
- 根目录已移除 `check:parity`,因为它依赖已删掉的页面对比记录。
|
||||||
|
- `genealogy-app-openapi.yaml` 仍保留。它记录接口描述,改服务层前先核对它和现有 `services/api/` 实现是否一致。
|
||||||
|
|
||||||
|
提交前先看一次 `git status`。当前工作区包含上述删除和命令调整,确认无误后再提交。
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
|
||||||
|
<link rel="icon" type="image/png" href="/static/assets/foundation/transparent/brand-seal.png" />
|
||||||
<title></title>
|
<title></title>
|
||||||
<script type="module" src="/main.js"></script>
|
<script type="module" src="/main.js"></script>
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
@@ -1,14 +1,24 @@
|
|||||||
{
|
{
|
||||||
"name": "家谱",
|
"name" : "代代相传家谱",
|
||||||
"appid": "__UNI__JIAPUAPP",
|
"appid" : "__UNI__2E0520B",
|
||||||
"description" : "家祠卷轴风格的家谱共建应用",
|
"description" : "家祠卷轴风格的家谱共建应用",
|
||||||
"versionName" : "1.0.0",
|
"versionName" : "1.0.0",
|
||||||
"versionCode" : 100,
|
"versionCode" : 100,
|
||||||
"transformPx" : false,
|
"transformPx" : false,
|
||||||
"app-plus" : {
|
"app-plus" : {
|
||||||
"usingComponents" : true,
|
"usingComponents" : true,
|
||||||
|
"modules" : {
|
||||||
|
"Payment" : {},
|
||||||
|
"Share" : {},
|
||||||
|
"OAuth" : {}
|
||||||
|
},
|
||||||
"nvueStyleCompiler" : "uni-app",
|
"nvueStyleCompiler" : "uni-app",
|
||||||
"compilerVersion" : 3,
|
"compilerVersion" : 3,
|
||||||
|
"statusbar" : {
|
||||||
|
"immersed" : "supportedDevice",
|
||||||
|
"style" : "light",
|
||||||
|
"background" : "#B52E22"
|
||||||
|
},
|
||||||
"splashscreen" : {
|
"splashscreen" : {
|
||||||
"alwaysShowBeforeRender" : true,
|
"alwaysShowBeforeRender" : true,
|
||||||
"waiting" : true,
|
"waiting" : true,
|
||||||
@@ -20,16 +30,50 @@
|
|||||||
"permissions" : [
|
"permissions" : [
|
||||||
"<uses-permission android:name=\"android.permission.ACCESS_NETWORK_STATE\"/>",
|
"<uses-permission android:name=\"android.permission.ACCESS_NETWORK_STATE\"/>",
|
||||||
"<uses-permission android:name=\"android.permission.INTERNET\"/>",
|
"<uses-permission android:name=\"android.permission.INTERNET\"/>",
|
||||||
"<uses-permission android:name=\"android.permission.READ_MEDIA_IMAGES\"/>"
|
"<uses-permission android:name=\"android.permission.READ_EXTERNAL_STORAGE\" android:maxSdkVersion=\"32\"/>",
|
||||||
|
"<uses-permission android:name=\"android.permission.READ_MEDIA_IMAGES\"/>",
|
||||||
|
"<uses-permission android:name=\"android.permission.READ_MEDIA_VIDEO\"/>"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"ios" : {
|
"ios" : {
|
||||||
"dSYMs": false
|
"dSYMs" : false,
|
||||||
}
|
"privacyDescription" : {
|
||||||
|
"NSPhotoLibraryUsageDescription" : "用于选择并上传您主动添加的家谱头像、照片和视频"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"mp-weixin": {
|
"icons" : {
|
||||||
"usingComponents": true
|
"android" : {
|
||||||
|
"hdpi" : "static/app-icons/72x72.png",
|
||||||
|
"xhdpi" : "static/app-icons/96x96.png",
|
||||||
|
"xxhdpi" : "static/app-icons/144x144.png",
|
||||||
|
"xxxhdpi" : "static/app-icons/192x192.png"
|
||||||
|
},
|
||||||
|
"ios" : {
|
||||||
|
"appstore" : "static/app-icons/1024x1024.png",
|
||||||
|
"ipad" : {
|
||||||
|
"app" : "static/app-icons/76x76.png",
|
||||||
|
"app@2x" : "static/app-icons/152x152.png",
|
||||||
|
"notification" : "static/app-icons/20x20.png",
|
||||||
|
"notification@2x" : "static/app-icons/40x40.png",
|
||||||
|
"proapp@2x" : "static/app-icons/167x167.png",
|
||||||
|
"settings" : "static/app-icons/29x29.png",
|
||||||
|
"settings@2x" : "static/app-icons/58x58.png",
|
||||||
|
"spotlight" : "static/app-icons/40x40.png",
|
||||||
|
"spotlight@2x" : "static/app-icons/80x80.png"
|
||||||
|
},
|
||||||
|
"iphone" : {
|
||||||
|
"app@2x" : "static/app-icons/120x120.png",
|
||||||
|
"app@3x" : "static/app-icons/180x180.png",
|
||||||
|
"notification@2x" : "static/app-icons/40x40.png",
|
||||||
|
"notification@3x" : "static/app-icons/60x60.png",
|
||||||
|
"settings@2x" : "static/app-icons/58x58.png",
|
||||||
|
"settings@3x" : "static/app-icons/87x87.png",
|
||||||
|
"spotlight@2x" : "static/app-icons/80x80.png",
|
||||||
|
"spotlight@3x" : "static/app-icons/120x120.png"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"vueVersion" : "3"
|
"vueVersion" : "3"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
{
|
||||||
|
"name": "jiapuapp",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {
|
||||||
|
"": {
|
||||||
|
"name": "jiapuapp",
|
||||||
|
"dependencies": {
|
||||||
|
"qrcode-generator": "^1.4.4"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"yaml": "^2.8.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/qrcode-generator": {
|
||||||
|
"version": "1.4.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/qrcode-generator/-/qrcode-generator-1.4.4.tgz",
|
||||||
|
"integrity": "sha512-HM7yY8O2ilqhmULxGMpcHSF1EhJJ9yBj8gvDEuZ6M+KGJ0YY2hKpnXvRD+hZPLrDVck3ExIGhmPtSdcjC+guuw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/yaml": {
|
||||||
|
"version": "2.8.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.1.tgz",
|
||||||
|
"integrity": "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC",
|
||||||
|
"bin": {
|
||||||
|
"yaml": "bin.mjs"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 14.6"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"name": "jiapuapp",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"check": "node scripts/check-project.mjs && node scripts/check-audit-regressions.mjs && node scripts/check-navigation-recovery.mjs && npm --prefix design-pipeline run check",
|
||||||
|
"check:project": "node scripts/check-project.mjs",
|
||||||
|
"check:regressions": "node scripts/check-audit-regressions.mjs",
|
||||||
|
"check:assets": "node design-pipeline/scripts/validate-runtime-asset-inventory.mjs design-pipeline/manifests/runtime-assets.json"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"yaml": "^2.8.1"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"qrcode-generator": "^1.4.4"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,64 +1,384 @@
|
|||||||
{
|
{
|
||||||
"pages": [
|
"pages": [
|
||||||
{
|
{
|
||||||
"path": "pages/auth/login",
|
"path": "pages/auth/sign-in",
|
||||||
"style": { "navigationStyle": "custom", "enablePullDownRefresh": false }
|
"style": {
|
||||||
|
"navigationStyle": "custom",
|
||||||
|
"enablePullDownRefresh": false
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "pages/genealogy/index",
|
"path": "pages/auth/register",
|
||||||
"style": { "navigationStyle": "custom", "enablePullDownRefresh": false }
|
"style": {
|
||||||
|
"navigationStyle": "custom"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/auth/reset-password",
|
||||||
|
"style": {
|
||||||
|
"navigationStyle": "custom"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/genealogy/my-genealogies",
|
||||||
|
"style": {
|
||||||
|
"navigationStyle": "custom",
|
||||||
|
"enablePullDownRefresh": false
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "pages/genealogy/create",
|
"path": "pages/genealogy/create",
|
||||||
"style": { "navigationStyle": "custom" }
|
"style": {
|
||||||
|
"navigationStyle": "custom"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "pages/genealogy/detail",
|
"path": "pages/genealogy/overview",
|
||||||
"style": { "navigationStyle": "custom" }
|
"style": {
|
||||||
|
"navigationStyle": "custom"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "pages/genealogy/search",
|
"path": "pages/genealogy/search",
|
||||||
"style": { "navigationStyle": "custom" }
|
"style": {
|
||||||
|
"navigationStyle": "custom"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "pages/genealogy/applications",
|
"path": "pages/genealogy/join-application",
|
||||||
"style": { "navigationStyle": "custom" }
|
"style": {
|
||||||
|
"navigationStyle": "custom"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "pages/lineage/first-person",
|
"path": "pages/genealogy/my-applications",
|
||||||
"style": { "navigationStyle": "custom" }
|
"style": {
|
||||||
|
"navigationStyle": "custom"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "pages/content/list",
|
"path": "pages/genealogy/application-review",
|
||||||
"style": { "navigationStyle": "custom" }
|
"style": {
|
||||||
|
"navigationStyle": "custom"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "pages/content/editor",
|
"path": "pages/genealogy/settings",
|
||||||
"style": { "navigationStyle": "custom" }
|
"style": {
|
||||||
|
"navigationStyle": "custom"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "pages/tree/index",
|
"path": "pages/genealogy/generation-poems",
|
||||||
"style": { "navigationStyle": "custom", "disableScroll": true }
|
"style": {
|
||||||
|
"navigationStyle": "custom"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "pages/member/detail",
|
"path": "pages/genealogy/members",
|
||||||
"style": { "navigationStyle": "custom" }
|
"style": {
|
||||||
|
"navigationStyle": "custom"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "pages/family/index",
|
"path": "pages/genealogy/capability-center",
|
||||||
"style": { "navigationStyle": "custom" }
|
"style": {
|
||||||
|
"navigationStyle": "custom"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "pages/profile/index",
|
"path": "pages/tree/overview",
|
||||||
"style": { "navigationStyle": "custom" }
|
"style": {
|
||||||
|
"navigationStyle": "custom",
|
||||||
|
"disableScroll": true
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "pages/notification/index",
|
"path": "pages/tree/pedigree",
|
||||||
"style": { "navigationStyle": "custom" }
|
"style": {
|
||||||
|
"navigationStyle": "custom",
|
||||||
|
"disableScroll": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/tree/member-profile",
|
||||||
|
"style": {
|
||||||
|
"navigationStyle": "custom"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/tree/add-relative",
|
||||||
|
"style": {
|
||||||
|
"navigationStyle": "custom"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/tree/edit-member",
|
||||||
|
"style": {
|
||||||
|
"navigationStyle": "custom"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/tree/member-rank",
|
||||||
|
"style": {
|
||||||
|
"navigationStyle": "custom"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/tree/member-directory",
|
||||||
|
"style": {
|
||||||
|
"navigationStyle": "custom"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/tree/member-states",
|
||||||
|
"style": {
|
||||||
|
"navigationStyle": "custom"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/family/site-home",
|
||||||
|
"style": {
|
||||||
|
"navigationStyle": "custom"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/family/site-article-list",
|
||||||
|
"style": {
|
||||||
|
"navigationStyle": "custom"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/family/feed",
|
||||||
|
"style": {
|
||||||
|
"navigationStyle": "custom"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/family/feed-editor",
|
||||||
|
"style": {
|
||||||
|
"navigationStyle": "custom"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/family/feed-detail",
|
||||||
|
"style": {
|
||||||
|
"navigationStyle": "custom"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/family/articles",
|
||||||
|
"style": {
|
||||||
|
"navigationStyle": "custom"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/family/article-detail",
|
||||||
|
"style": {
|
||||||
|
"navigationStyle": "custom"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/family/article-editor",
|
||||||
|
"style": {
|
||||||
|
"navigationStyle": "custom"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/family/albums",
|
||||||
|
"style": {
|
||||||
|
"navigationStyle": "custom"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/family/album-detail",
|
||||||
|
"style": {
|
||||||
|
"navigationStyle": "custom"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/family/add-photo",
|
||||||
|
"style": {
|
||||||
|
"navigationStyle": "custom"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/family/videos",
|
||||||
|
"style": {
|
||||||
|
"navigationStyle": "custom"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/family/platform-videos",
|
||||||
|
"style": {
|
||||||
|
"navigationStyle": "custom"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/records/people",
|
||||||
|
"style": {
|
||||||
|
"navigationStyle": "custom"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/records/person-detail",
|
||||||
|
"style": {
|
||||||
|
"navigationStyle": "custom"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/records/relative-records",
|
||||||
|
"style": {
|
||||||
|
"navigationStyle": "custom"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/records/relative-record-editor",
|
||||||
|
"style": {
|
||||||
|
"navigationStyle": "custom"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/records/ceremonies",
|
||||||
|
"style": {
|
||||||
|
"navigationStyle": "custom"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/records/ceremony-detail",
|
||||||
|
"style": {
|
||||||
|
"navigationStyle": "custom"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/records/ceremony-editor",
|
||||||
|
"style": {
|
||||||
|
"navigationStyle": "custom"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/records/growth-journal",
|
||||||
|
"style": {
|
||||||
|
"navigationStyle": "custom"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/records/life-events",
|
||||||
|
"style": {
|
||||||
|
"navigationStyle": "custom"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/records/memos",
|
||||||
|
"style": {
|
||||||
|
"navigationStyle": "custom"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/records/merit-records",
|
||||||
|
"style": {
|
||||||
|
"navigationStyle": "custom"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/records/person-documents",
|
||||||
|
"style": {
|
||||||
|
"navigationStyle": "custom"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/notification/message-center",
|
||||||
|
"style": {
|
||||||
|
"navigationStyle": "custom"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/notification/message-detail",
|
||||||
|
"style": {
|
||||||
|
"navigationStyle": "custom"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/profile/home",
|
||||||
|
"style": {
|
||||||
|
"navigationStyle": "custom"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/profile/edit-profile",
|
||||||
|
"style": {
|
||||||
|
"navigationStyle": "custom"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/profile/security",
|
||||||
|
"style": {
|
||||||
|
"navigationStyle": "custom"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/profile/change-password",
|
||||||
|
"style": {
|
||||||
|
"navigationStyle": "custom"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/profile/change-phone",
|
||||||
|
"style": {
|
||||||
|
"navigationStyle": "custom"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/profile/help",
|
||||||
|
"style": {
|
||||||
|
"navigationStyle": "custom"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/profile/feedback",
|
||||||
|
"style": {
|
||||||
|
"navigationStyle": "custom"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/profile/promotions",
|
||||||
|
"style": {
|
||||||
|
"navigationStyle": "custom"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/profile/vip",
|
||||||
|
"style": {
|
||||||
|
"navigationStyle": "custom"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/profile/settings",
|
||||||
|
"style": {
|
||||||
|
"navigationStyle": "custom"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/profile/ceremony-invitations",
|
||||||
|
"style": {
|
||||||
|
"navigationStyle": "custom"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/profile/earnings",
|
||||||
|
"style": {
|
||||||
|
"navigationStyle": "custom"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/profile/compliance-document",
|
||||||
|
"style": {
|
||||||
|
"navigationStyle": "custom"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"globalStyle": {
|
"globalStyle": {
|
||||||
"navigationBarTextStyle": "black",
|
"navigationBarTextStyle": "white",
|
||||||
"navigationBarTitleText": "家谱",
|
"navigationBarTitleText": "家谱",
|
||||||
"navigationBarBackgroundColor": "#F6F0E5",
|
"navigationBarBackgroundColor": "#F6F0E5",
|
||||||
"backgroundColor": "#F6F0E5"
|
"backgroundColor": "#F6F0E5"
|
||||||
|
|||||||
@@ -1,84 +0,0 @@
|
|||||||
<template>
|
|
||||||
<view class="page-shell login-page">
|
|
||||||
<view class="ceremony-top">
|
|
||||||
<text class="seal-word">谱</text>
|
|
||||||
<text class="eyebrow">家脉绵延 · 生生不息</text>
|
|
||||||
<text class="brand-title">家谱</text>
|
|
||||||
<text class="brand-subtitle">为家族留存可传承的记忆</text>
|
|
||||||
</view>
|
|
||||||
|
|
||||||
<view class="login-paper">
|
|
||||||
<view class="section-title">登录家谱</view>
|
|
||||||
<text class="form-note">使用已注册手机号和密码登录。</text>
|
|
||||||
<text class="form-label">手机号</text>
|
|
||||||
<input v-model="phone" class="line-input" type="number" maxlength="11" placeholder="请输入手机号" placeholder-class="placeholder" />
|
|
||||||
<text class="form-label">密码</text>
|
|
||||||
<input v-model="password" class="line-input" password placeholder="请输入密码" placeholder-class="placeholder" />
|
|
||||||
<text v-if="errorMessage" class="error-message">{{ errorMessage }}</text>
|
|
||||||
<button class="primary-button login-button" :loading="isSubmitting" :disabled="isSubmitting" @click="login">进入家谱</button>
|
|
||||||
<text class="login-tip">短信登录将在验证码服务联调后开放。</text>
|
|
||||||
<text class="terms">登录即表示同意《用户协议》与《隐私政策》</text>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { ref } from 'vue'
|
|
||||||
import { appApi } from '@/utils/api.js'
|
|
||||||
import { calcMD5 } from '@/utils/md5.js'
|
|
||||||
|
|
||||||
const phone = ref('')
|
|
||||||
const password = ref('')
|
|
||||||
const isSubmitting = ref(false)
|
|
||||||
const errorMessage = ref('')
|
|
||||||
|
|
||||||
const login = async () => {
|
|
||||||
if (!/^1\d{10}$/.test(phone.value)) {
|
|
||||||
uni.showToast({ title: '请输入正确手机号', icon: 'none' })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (!password.value) {
|
|
||||||
uni.showToast({ title: '请输入登录密码', icon: 'none' })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
isSubmitting.value = true
|
|
||||||
errorMessage.value = ''
|
|
||||||
try {
|
|
||||||
await appApi.loginWithPassword({ phone: phone.value, passwordHash: calcMD5(password.value) })
|
|
||||||
uni.reLaunch({ url: '/pages/genealogy/index' })
|
|
||||||
} catch (error) {
|
|
||||||
errorMessage.value = error.message || '登录失败,请稍后重试'
|
|
||||||
} finally {
|
|
||||||
isSubmitting.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped lang="scss">
|
|
||||||
.login-page { padding-top: 108rpx; }
|
|
||||||
|
|
||||||
.ceremony-top {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
padding: 40rpx 40rpx 76rpx;
|
|
||||||
background: $brand-red;
|
|
||||||
color: #fff9ed;
|
|
||||||
}
|
|
||||||
|
|
||||||
.seal-word { display: flex; width: 66rpx; height: 66rpx; align-items: center; justify-content: center; box-sizing: border-box; border: 2rpx solid #f1d494; border-radius: 50%; color: #f5ddaa; font-family: serif; font-size: 37rpx; }
|
|
||||||
.eyebrow { margin-top: 24rpx; color: #f8dfad; font-size: 22rpx; letter-spacing: 5rpx; }
|
|
||||||
.brand-title { margin-top: 24rpx; font-family: serif; font-size: 68rpx; font-weight: 700; letter-spacing: 16rpx; }
|
|
||||||
.brand-subtitle { margin-top: 18rpx; color: #f5deae; font-size: 26rpx; letter-spacing: 3rpx; }
|
|
||||||
|
|
||||||
.login-paper { margin: -28rpx 28rpx 0; padding: 48rpx 42rpx; border: 1rpx solid $gold-soft; border-radius: 24rpx; background: $paper-white; }
|
|
||||||
.form-note { display: block; margin-top: 14rpx; color: $ink-muted; font-size: 23rpx; }
|
|
||||||
.form-label { display: block; margin-top: 40rpx; color: $ink-muted; font-size: 25rpx; }
|
|
||||||
.line-input { height: 78rpx; border-bottom: 1rpx solid $gold-soft; color: $ink; font-size: 30rpx; }
|
|
||||||
.placeholder { color: #b3a895; }
|
|
||||||
.error-message { display: block; margin-top: 22rpx; color: $brand-red; font-size: 23rpx; line-height: 1.5; }
|
|
||||||
.login-button { margin-top: 48rpx; }
|
|
||||||
.login-tip { display: block; margin-top: 24rpx; color: $ink-muted; font-size: 22rpx; text-align: center; }
|
|
||||||
.terms { display: block; margin-top: 16rpx; color: #a39785; font-size: 21rpx; text-align: center; }
|
|
||||||
</style>
|
|
||||||
@@ -0,0 +1,875 @@
|
|||||||
|
<template>
|
||||||
|
<AuthPageShell class="auth-page register-page">
|
||||||
|
<view class="register-content">
|
||||||
|
<view class="page-heading">
|
||||||
|
<button
|
||||||
|
class="auth-plain-button back-button"
|
||||||
|
aria-label="返回登录"
|
||||||
|
hover-class="tap-fade"
|
||||||
|
@click="requestBack"
|
||||||
|
>
|
||||||
|
<image
|
||||||
|
class="back-button__icon"
|
||||||
|
src="/static/assets/foundation/transparent/chevron-right.png"
|
||||||
|
mode="aspectFit"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
<text class="page-title">注册账号</text>
|
||||||
|
<text class="page-subtitle">创建属于你的家谱账号</text>
|
||||||
|
<image
|
||||||
|
class="register-divider"
|
||||||
|
src="/static/assets/modules/auth/transparent/title-divider.png"
|
||||||
|
mode="aspectFit"
|
||||||
|
/>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="form-content">
|
||||||
|
<view
|
||||||
|
class="field-block"
|
||||||
|
:class="{ 'field-block--error': fieldErrors.phone }"
|
||||||
|
>
|
||||||
|
<view class="input-row">
|
||||||
|
<label class="input-label" for="register-phone"
|
||||||
|
><text class="required-mark">*</text>手机号</label
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
id="register-phone"
|
||||||
|
v-model.trim="phone"
|
||||||
|
class="auth-input"
|
||||||
|
type="number"
|
||||||
|
maxlength="11"
|
||||||
|
:disabled="
|
||||||
|
sendingCode ||
|
||||||
|
submitting ||
|
||||||
|
tacVisible ||
|
||||||
|
registrationCommitted ||
|
||||||
|
(cooldownSeconds > 0 && phone.length > 0)
|
||||||
|
"
|
||||||
|
placeholder="请输入手机号"
|
||||||
|
placeholder-class="placeholder"
|
||||||
|
:aria-invalid="Boolean(fieldErrors.phone)"
|
||||||
|
:aria-describedby="
|
||||||
|
fieldErrors.phone ? 'register-phone-error' : undefined
|
||||||
|
"
|
||||||
|
@input="handlePhoneInput"
|
||||||
|
/>
|
||||||
|
</view>
|
||||||
|
<text
|
||||||
|
v-if="fieldErrors.phone"
|
||||||
|
id="register-phone-error"
|
||||||
|
class="field-error"
|
||||||
|
role="alert"
|
||||||
|
>{{ fieldErrors.phone }}</text
|
||||||
|
>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="field-block">
|
||||||
|
<view class="input-row">
|
||||||
|
<label class="input-label" for="register-nickname">昵称</label>
|
||||||
|
<input
|
||||||
|
id="register-nickname"
|
||||||
|
v-model.trim="nickName"
|
||||||
|
class="auth-input"
|
||||||
|
:disabled="submitting || registrationCommitted"
|
||||||
|
placeholder="请输入昵称"
|
||||||
|
placeholder-class="placeholder"
|
||||||
|
/>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view
|
||||||
|
class="field-block"
|
||||||
|
:class="{ 'field-block--error': fieldErrors.referralCode }"
|
||||||
|
>
|
||||||
|
<view class="input-row">
|
||||||
|
<label class="input-label" for="register-referral-code">推荐码</label>
|
||||||
|
<input
|
||||||
|
id="register-referral-code"
|
||||||
|
v-model.trim="referralCode"
|
||||||
|
class="auth-input"
|
||||||
|
maxlength="64"
|
||||||
|
:disabled="submitting || registrationCommitted"
|
||||||
|
placeholder="选填,来自邀请人的推荐码"
|
||||||
|
placeholder-class="placeholder"
|
||||||
|
:aria-invalid="Boolean(fieldErrors.referralCode)"
|
||||||
|
:aria-describedby="fieldErrors.referralCode ? 'register-referral-code-error' : undefined"
|
||||||
|
@input="clearFieldError('referralCode')"
|
||||||
|
/>
|
||||||
|
</view>
|
||||||
|
<text
|
||||||
|
v-if="fieldErrors.referralCode"
|
||||||
|
id="register-referral-code-error"
|
||||||
|
class="field-error"
|
||||||
|
role="alert"
|
||||||
|
>{{ fieldErrors.referralCode }}</text
|
||||||
|
>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view
|
||||||
|
class="field-block"
|
||||||
|
:class="{ 'field-block--error': fieldErrors.verificationCode }"
|
||||||
|
>
|
||||||
|
<view class="input-row code-row">
|
||||||
|
<label class="input-label" for="register-verification-code"
|
||||||
|
><text class="required-mark">*</text>验证码</label
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
id="register-verification-code"
|
||||||
|
v-model.trim="verificationCode"
|
||||||
|
class="auth-input"
|
||||||
|
type="number"
|
||||||
|
maxlength="4"
|
||||||
|
:disabled="submitting || registrationCommitted"
|
||||||
|
placeholder="请输入验证码"
|
||||||
|
placeholder-class="placeholder"
|
||||||
|
:aria-invalid="Boolean(fieldErrors.verificationCode)"
|
||||||
|
:aria-describedby="
|
||||||
|
fieldErrors.verificationCode
|
||||||
|
? 'register-verification-code-error'
|
||||||
|
: undefined
|
||||||
|
"
|
||||||
|
@input="clearFieldError('verificationCode')"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
class="auth-plain-button get-code"
|
||||||
|
:class="{
|
||||||
|
'get-code--disabled': sendingCode || cooldownSeconds > 0,
|
||||||
|
}"
|
||||||
|
:disabled="
|
||||||
|
sendingCode ||
|
||||||
|
submitting ||
|
||||||
|
cooldownSeconds > 0 ||
|
||||||
|
registrationCommitted
|
||||||
|
"
|
||||||
|
hover-class="tap-fade"
|
||||||
|
@click="prepareGetCode"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
sendingCode
|
||||||
|
? "请求中…"
|
||||||
|
: cooldownSeconds > 0
|
||||||
|
? `${cooldownSeconds}s 后重发`
|
||||||
|
: "获取验证码"
|
||||||
|
}}
|
||||||
|
</button>
|
||||||
|
</view>
|
||||||
|
<text
|
||||||
|
v-if="fieldErrors.verificationCode"
|
||||||
|
id="register-verification-code-error"
|
||||||
|
class="field-error"
|
||||||
|
role="alert"
|
||||||
|
>{{ fieldErrors.verificationCode }}</text
|
||||||
|
>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view
|
||||||
|
class="field-block"
|
||||||
|
:class="{ 'field-block--error': fieldErrors.password }"
|
||||||
|
>
|
||||||
|
<view class="input-row">
|
||||||
|
<label class="input-label input-label--password" for="register-password"
|
||||||
|
><text class="required-mark">*</text>设置密码</label
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
id="register-password"
|
||||||
|
v-model="password"
|
||||||
|
class="auth-input"
|
||||||
|
password
|
||||||
|
maxlength="32"
|
||||||
|
:disabled="submitting || registrationCommitted"
|
||||||
|
placeholder="请设置登录密码"
|
||||||
|
placeholder-class="placeholder"
|
||||||
|
:aria-invalid="Boolean(fieldErrors.password)"
|
||||||
|
:aria-describedby="
|
||||||
|
fieldErrors.password ? 'register-password-error' : undefined
|
||||||
|
"
|
||||||
|
@input="clearFieldError('password')"
|
||||||
|
/>
|
||||||
|
</view>
|
||||||
|
<text
|
||||||
|
v-if="fieldErrors.password"
|
||||||
|
id="register-password-error"
|
||||||
|
class="field-error"
|
||||||
|
role="alert"
|
||||||
|
>{{ fieldErrors.password }}</text
|
||||||
|
>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view
|
||||||
|
class="field-block"
|
||||||
|
:class="{ 'field-block--error': fieldErrors.confirmPassword }"
|
||||||
|
>
|
||||||
|
<view class="input-row">
|
||||||
|
<label class="input-label input-label--password" for="register-confirm-password"
|
||||||
|
><text class="required-mark">*</text>确认密码</label
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
id="register-confirm-password"
|
||||||
|
v-model="confirmPassword"
|
||||||
|
class="auth-input"
|
||||||
|
password
|
||||||
|
maxlength="32"
|
||||||
|
:disabled="submitting || registrationCommitted"
|
||||||
|
placeholder="请再次输入密码"
|
||||||
|
placeholder-class="placeholder"
|
||||||
|
:aria-invalid="Boolean(fieldErrors.confirmPassword)"
|
||||||
|
:aria-describedby="
|
||||||
|
fieldErrors.confirmPassword
|
||||||
|
? 'register-confirm-password-error'
|
||||||
|
: undefined
|
||||||
|
"
|
||||||
|
@input="clearFieldError('confirmPassword')"
|
||||||
|
/>
|
||||||
|
</view>
|
||||||
|
<text
|
||||||
|
v-if="fieldErrors.confirmPassword"
|
||||||
|
id="register-confirm-password-error"
|
||||||
|
class="field-error"
|
||||||
|
role="alert"
|
||||||
|
>{{ fieldErrors.confirmPassword }}</text
|
||||||
|
>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="auth-plain-button register-submit"
|
||||||
|
:disabled="submitting || sendingCode || tacVisible"
|
||||||
|
:aria-busy="submitting"
|
||||||
|
hover-class="button-hover"
|
||||||
|
@click="submitRegister"
|
||||||
|
>
|
||||||
|
<image
|
||||||
|
class="register-submit__skin"
|
||||||
|
src="/static/assets/foundation/transparent/scroll-primary.png"
|
||||||
|
mode="aspectFit"
|
||||||
|
/>
|
||||||
|
<text class="register-submit__content">{{
|
||||||
|
submitting
|
||||||
|
? registrationCommitted
|
||||||
|
? "正在进入…"
|
||||||
|
: "注册中…"
|
||||||
|
: registrationCommitted
|
||||||
|
? "进入家谱"
|
||||||
|
: "注册账号"
|
||||||
|
}}</text>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<view
|
||||||
|
class="agreement-area"
|
||||||
|
:class="{ 'agreement-area--error': agreementError }"
|
||||||
|
>
|
||||||
|
<view class="agreement-row">
|
||||||
|
<button
|
||||||
|
class="auth-plain-button agreement-toggle"
|
||||||
|
role="checkbox"
|
||||||
|
:aria-checked="agreed"
|
||||||
|
:aria-label="
|
||||||
|
agreed ? '取消同意用户协议与隐私政策' : '同意用户协议与隐私政策'
|
||||||
|
"
|
||||||
|
:disabled="
|
||||||
|
sendingCode || submitting || tacVisible || registrationCommitted
|
||||||
|
"
|
||||||
|
@click="toggleAgreement"
|
||||||
|
>
|
||||||
|
<image
|
||||||
|
class="agreement-icon"
|
||||||
|
:src="
|
||||||
|
agreed
|
||||||
|
? '/static/assets/modules/auth/transparent/agreement-checked.png'
|
||||||
|
: '/static/assets/modules/auth/transparent/agreement-unchecked.png'
|
||||||
|
"
|
||||||
|
mode="aspectFit"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
<view class="agreement-copy">
|
||||||
|
<text>我已阅读并同意</text>
|
||||||
|
<button
|
||||||
|
class="auth-plain-button agreement-link"
|
||||||
|
@click="openComplianceDocument('user_agreement')"
|
||||||
|
>
|
||||||
|
《用户协议》
|
||||||
|
</button>
|
||||||
|
<text>与</text>
|
||||||
|
<button
|
||||||
|
class="auth-plain-button agreement-link"
|
||||||
|
@click="openComplianceDocument('privacy_policy')"
|
||||||
|
>
|
||||||
|
《隐私政策》
|
||||||
|
</button>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<text v-if="agreementError" class="agreement-error" role="alert"
|
||||||
|
>请先阅读并同意用户协议与隐私政策</text
|
||||||
|
>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="login-entry">
|
||||||
|
<text>已有账号?</text>
|
||||||
|
<button
|
||||||
|
class="auth-plain-button login-entry__link"
|
||||||
|
:disabled="submitting || sendingCode || tacVisible"
|
||||||
|
hover-class="tap-fade"
|
||||||
|
@click="requestBack"
|
||||||
|
>
|
||||||
|
登录
|
||||||
|
</button>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<template #overlay>
|
||||||
|
<TacVerification
|
||||||
|
:visible="tacVisible"
|
||||||
|
:context="tacContext"
|
||||||
|
@success="completeTac"
|
||||||
|
@failure="handleTacFailure"
|
||||||
|
@error="handleTacError"
|
||||||
|
@cancel="closeTac"
|
||||||
|
/>
|
||||||
|
<AppDialog
|
||||||
|
:visible="discardVisible"
|
||||||
|
title="放弃注册?"
|
||||||
|
message="当前填写内容尚未保存,确认返回后将清空。"
|
||||||
|
confirm-text="放弃并返回"
|
||||||
|
cancel-text="继续填写"
|
||||||
|
show-cancel
|
||||||
|
compact-actions
|
||||||
|
:close-on-mask="false"
|
||||||
|
@confirm="confirmDiscard"
|
||||||
|
@cancel="cancelDiscard"
|
||||||
|
/>
|
||||||
|
<AppToast :visible="feedbackVisible" :message="feedbackMessage" />
|
||||||
|
</template>
|
||||||
|
</AuthPageShell>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, ref } from "vue";
|
||||||
|
import { onBackPress, onLoad, onShow, onUnload } from "@dcloudio/uni-app";
|
||||||
|
import AppDialog from "@/components/AppDialog.vue";
|
||||||
|
import AppToast from "@/components/AppToast.vue";
|
||||||
|
import AuthPageShell from "@/components/auth/PageShell.vue";
|
||||||
|
import TacVerification from "@/components/auth/TacVerification.vue";
|
||||||
|
import { useSmsVerification } from "@/composables/auth/use-sms-verification.js";
|
||||||
|
import {
|
||||||
|
createRequestController,
|
||||||
|
isRequestCancelled
|
||||||
|
} from "@/services/api/request-controller.js";
|
||||||
|
import { authApi } from "@/services/api/auth-service.js";
|
||||||
|
import {
|
||||||
|
AUTH_VERIFICATION_OPERATION,
|
||||||
|
isAuthPhone,
|
||||||
|
} from "@/utils/auth/verification.js";
|
||||||
|
import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
|
||||||
|
import { calcMD5 } from "@/utils/md5.js";
|
||||||
|
import { createNonIdempotentWriteGuard } from "@/utils/non-idempotent-write-guard.js";
|
||||||
|
import {
|
||||||
|
goRoot,
|
||||||
|
handleBackPress,
|
||||||
|
openPage,
|
||||||
|
runBackGuard,
|
||||||
|
} from "@/utils/navigation/gateway.js";
|
||||||
|
import {
|
||||||
|
PASSWORD_POLICY_MESSAGE,
|
||||||
|
validatePassword,
|
||||||
|
} from "@/utils/auth/password-policy.js";
|
||||||
|
|
||||||
|
const phone = ref("");
|
||||||
|
const nickName = ref("");
|
||||||
|
const referralCode = ref("");
|
||||||
|
const verificationCode = ref("");
|
||||||
|
const password = ref("");
|
||||||
|
const confirmPassword = ref("");
|
||||||
|
const agreed = ref(false);
|
||||||
|
const agreementError = ref(false);
|
||||||
|
const fieldErrors = ref({
|
||||||
|
phone: "",
|
||||||
|
referralCode: "",
|
||||||
|
verificationCode: "",
|
||||||
|
password: "",
|
||||||
|
confirmPassword: "",
|
||||||
|
});
|
||||||
|
const feedbackVisible = ref(false);
|
||||||
|
const feedbackMessage = ref("");
|
||||||
|
const discardVisible = ref(false);
|
||||||
|
const submitting = ref(false);
|
||||||
|
const registrationCommitted = ref(false);
|
||||||
|
const isDirty = computed(
|
||||||
|
() =>
|
||||||
|
!registrationCommitted.value &&
|
||||||
|
Boolean(
|
||||||
|
phone.value ||
|
||||||
|
nickName.value ||
|
||||||
|
referralCode.value ||
|
||||||
|
verificationCode.value ||
|
||||||
|
password.value ||
|
||||||
|
confirmPassword.value ||
|
||||||
|
agreed.value,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
let feedbackTimer = null;
|
||||||
|
let pageActive = true;
|
||||||
|
|
||||||
|
onLoad((options = {}) => {
|
||||||
|
referralCode.value = String(options.referralCode || "").trim().slice(0, 64);
|
||||||
|
});
|
||||||
|
const registrationNavigationFailure =
|
||||||
|
"注册已完成,但暂时无法进入家谱,请再次点击进入";
|
||||||
|
const registrationSubmissionRequestController = createRequestController();
|
||||||
|
const registrationGuard = createNonIdempotentWriteGuard();
|
||||||
|
const discardConfirmation = createDiscardConfirmation((visible) => {
|
||||||
|
discardVisible.value = visible;
|
||||||
|
});
|
||||||
|
const requestDiscardConfirmation = discardConfirmation.request;
|
||||||
|
const confirmDiscard = discardConfirmation.confirm;
|
||||||
|
const cancelDiscard = discardConfirmation.cancel;
|
||||||
|
|
||||||
|
const requestBack = () =>
|
||||||
|
registrationCommitted.value
|
||||||
|
? enterAuthenticatedRoot()
|
||||||
|
: runBackGuard({
|
||||||
|
transientOpen: tacVisible.value || discardVisible.value,
|
||||||
|
submitting: submitting.value || sendingCode.value,
|
||||||
|
dirty: isDirty.value,
|
||||||
|
"close-transient": tacVisible.value ? closeTac : cancelDiscard,
|
||||||
|
"block-submitting": () => true,
|
||||||
|
"confirm-discard": requestDiscardConfirmation,
|
||||||
|
});
|
||||||
|
|
||||||
|
onBackPress((event) => handleBackPress(event, requestBack));
|
||||||
|
|
||||||
|
onUnload(() => {
|
||||||
|
pageActive = false;
|
||||||
|
smsVerification.dispose();
|
||||||
|
registrationSubmissionRequestController.abort();
|
||||||
|
if (feedbackTimer) clearTimeout(feedbackTimer);
|
||||||
|
discardConfirmation.dispose();
|
||||||
|
});
|
||||||
|
onShow(() => smsVerification.syncCooldown());
|
||||||
|
|
||||||
|
const showFeedback = (message) => {
|
||||||
|
feedbackMessage.value = message;
|
||||||
|
feedbackVisible.value = true;
|
||||||
|
if (feedbackTimer) clearTimeout(feedbackTimer);
|
||||||
|
feedbackTimer = setTimeout(() => {
|
||||||
|
feedbackVisible.value = false;
|
||||||
|
feedbackTimer = null;
|
||||||
|
}, 2200);
|
||||||
|
};
|
||||||
|
|
||||||
|
const smsVerification = useSmsVerification({
|
||||||
|
operationCode: AUTH_VERIFICATION_OPERATION.REGISTER,
|
||||||
|
requestIdPrefix: "register",
|
||||||
|
phone,
|
||||||
|
isActive: () => pageActive,
|
||||||
|
showFeedback,
|
||||||
|
});
|
||||||
|
const {
|
||||||
|
tacVisible,
|
||||||
|
tacContext,
|
||||||
|
sendingCode,
|
||||||
|
cooldownSeconds,
|
||||||
|
sentPhone,
|
||||||
|
closeTac,
|
||||||
|
completeTac,
|
||||||
|
handleTacFailure,
|
||||||
|
handleTacError,
|
||||||
|
} = smsVerification;
|
||||||
|
|
||||||
|
const enterAuthenticatedRoot = async () => {
|
||||||
|
if (!pageActive) return false;
|
||||||
|
submitting.value = true;
|
||||||
|
try {
|
||||||
|
const opened = await goRoot("G01");
|
||||||
|
if (opened !== true) throw new Error("NAVIGATION_RETRY_REQUIRED");
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
if (pageActive) showFeedback(registrationNavigationFailure);
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
if (pageActive) submitting.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const openComplianceDocument = (documentKey) => {
|
||||||
|
if (sendingCode.value || submitting.value || registrationCommitted.value)
|
||||||
|
return;
|
||||||
|
return openPage("M13", { documentKey }, "A04");
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleAgreement = () => {
|
||||||
|
if (sendingCode.value || submitting.value || registrationCommitted.value)
|
||||||
|
return;
|
||||||
|
agreed.value = !agreed.value;
|
||||||
|
if (agreed.value) agreementError.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearFieldError = (field) => {
|
||||||
|
fieldErrors.value[field] = "";
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePhoneInput = () => {
|
||||||
|
clearFieldError("phone");
|
||||||
|
if (!sentPhone.value || phone.value === sentPhone.value) return;
|
||||||
|
verificationCode.value = "";
|
||||||
|
sentPhone.value = "";
|
||||||
|
};
|
||||||
|
|
||||||
|
const prepareGetCode = async () => {
|
||||||
|
if (sendingCode.value || submitting.value || cooldownSeconds.value > 0)
|
||||||
|
return;
|
||||||
|
if (!isAuthPhone(phone.value)) {
|
||||||
|
fieldErrors.value.phone = "请输入正确手机号";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
fieldErrors.value.phone = "";
|
||||||
|
if (!agreed.value) {
|
||||||
|
agreementError.value = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
return smsVerification.requestCode();
|
||||||
|
};
|
||||||
|
|
||||||
|
const validateForm = () => {
|
||||||
|
const nextErrors = {
|
||||||
|
phone: "",
|
||||||
|
referralCode: "",
|
||||||
|
verificationCode: "",
|
||||||
|
password: "",
|
||||||
|
confirmPassword: "",
|
||||||
|
};
|
||||||
|
if (!isAuthPhone(phone.value)) nextErrors.phone = "请输入正确手机号";
|
||||||
|
if (referralCode.value && !/^[A-Za-z0-9_-]{4,64}$/.test(referralCode.value)) {
|
||||||
|
nextErrors.referralCode = "推荐码应为 4 至 64 位字母、数字、短横线或下划线";
|
||||||
|
}
|
||||||
|
if (sentPhone.value !== phone.value)
|
||||||
|
nextErrors.verificationCode = "请先获取当前手机号的验证码";
|
||||||
|
else if (!/^\d{4}$/.test(verificationCode.value))
|
||||||
|
nextErrors.verificationCode = "请输入 4 位验证码";
|
||||||
|
const passwordResult = validatePassword(password.value);
|
||||||
|
if (!passwordResult.valid) nextErrors.password = PASSWORD_POLICY_MESSAGE;
|
||||||
|
if (!confirmPassword.value) nextErrors.confirmPassword = "请再次输入密码";
|
||||||
|
else if (password.value !== confirmPassword.value)
|
||||||
|
nextErrors.confirmPassword = "两次输入的密码不一致";
|
||||||
|
fieldErrors.value = nextErrors;
|
||||||
|
return !Object.values(nextErrors).some(Boolean);
|
||||||
|
};
|
||||||
|
|
||||||
|
const submitRegister = async () => {
|
||||||
|
if (submitting.value || sendingCode.value) return;
|
||||||
|
if (registrationCommitted.value) return enterAuthenticatedRoot();
|
||||||
|
const formValid = validateForm();
|
||||||
|
if (!agreed.value) agreementError.value = true;
|
||||||
|
if (!formValid || !agreed.value) return;
|
||||||
|
const registrationPayload = {
|
||||||
|
phone: phone.value,
|
||||||
|
nickName: nickName.value,
|
||||||
|
referralCode: referralCode.value,
|
||||||
|
passwordHash: calcMD5(password.value),
|
||||||
|
smsCode: verificationCode.value,
|
||||||
|
};
|
||||||
|
const registrationAttempt = registrationGuard.begin(registrationPayload);
|
||||||
|
if (registrationAttempt === null) {
|
||||||
|
showFeedback(
|
||||||
|
"上次注册结果暂时无法确认,请先返回登录页尝试登录,不要重复注册。",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
submitting.value = true;
|
||||||
|
try {
|
||||||
|
await authApi.registerWithPassword(
|
||||||
|
registrationPayload,
|
||||||
|
{ requestController: registrationSubmissionRequestController },
|
||||||
|
);
|
||||||
|
if (!pageActive) return;
|
||||||
|
registrationCommitted.value = true;
|
||||||
|
} catch (error) {
|
||||||
|
if (!pageActive) return;
|
||||||
|
if (registrationGuard.recordFailure(registrationAttempt, error)) {
|
||||||
|
showFeedback(
|
||||||
|
"注册结果暂时无法确认,请先返回登录页尝试登录,不要重复注册。",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!isRequestCancelled(error))
|
||||||
|
showFeedback(error.message || "注册失败,请稍后重试");
|
||||||
|
return;
|
||||||
|
} finally {
|
||||||
|
if (pageActive) submitting.value = false;
|
||||||
|
}
|
||||||
|
if (!pageActive) return;
|
||||||
|
await enterAuthenticatedRoot();
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.register-content {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: flex-start;
|
||||||
|
box-sizing: border-box;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 480px;
|
||||||
|
min-width: 0;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: clamp(10px, 2.2vh, 20px) clamp(24px, 8.5vw, 44px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-heading {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
grid-template-rows: auto auto auto;
|
||||||
|
justify-items: center;
|
||||||
|
min-height: 154rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-button {
|
||||||
|
display: flex;
|
||||||
|
grid-row: 1 / -1;
|
||||||
|
grid-column: 1;
|
||||||
|
align-self: start;
|
||||||
|
justify-self: start;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: var(--app-touch-min);
|
||||||
|
height: var(--app-touch-min);
|
||||||
|
margin-top: 10rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-button__icon {
|
||||||
|
width: 42rpx;
|
||||||
|
height: 42rpx;
|
||||||
|
transform: scaleX(-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-title {
|
||||||
|
grid-column: 1;
|
||||||
|
grid-row: 1;
|
||||||
|
color: #9f170f;
|
||||||
|
font-size: clamp(29px, 58rpx, 36px);
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 7rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-subtitle {
|
||||||
|
grid-column: 1;
|
||||||
|
grid-row: 2;
|
||||||
|
margin-top: 12rpx;
|
||||||
|
color: #806c58;
|
||||||
|
font-size: clamp(15px, 25rpx, 18px);
|
||||||
|
letter-spacing: 2rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.register-divider {
|
||||||
|
grid-column: 1;
|
||||||
|
grid-row: 3;
|
||||||
|
width: 300rpx;
|
||||||
|
height: 50rpx;
|
||||||
|
filter: brightness(0.68) saturate(1.5) contrast(1.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-content {
|
||||||
|
margin-top: 12rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-block {
|
||||||
|
min-height: 116rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
min-height: var(--app-touch-min);
|
||||||
|
border-bottom: 1rpx solid rgba(182, 116, 43, 0.68);
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-label {
|
||||||
|
flex: 0 0 152rpx;
|
||||||
|
color: #674b35;
|
||||||
|
font-size: clamp(17px, 30rpx, 22px);
|
||||||
|
}
|
||||||
|
.input-label--password {
|
||||||
|
flex-basis: 220rpx;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.required-mark {
|
||||||
|
display: inline-block;
|
||||||
|
margin-right: 4rpx;
|
||||||
|
color: #b42318;
|
||||||
|
font-size: clamp(16px, 26rpx, 20px);
|
||||||
|
line-height: 1;
|
||||||
|
transform: translateY(-3rpx);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-input {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: var(--app-touch-min);
|
||||||
|
color: #493323;
|
||||||
|
font-size: clamp(17px, 30rpx, 22px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.get-code {
|
||||||
|
display: flex;
|
||||||
|
flex: 0 0 176rpx;
|
||||||
|
box-sizing: border-box;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
padding: 0 8rpx;
|
||||||
|
border-left: 1rpx solid #d7bd94;
|
||||||
|
background: transparent !important;
|
||||||
|
color: #a7160c;
|
||||||
|
font-size: clamp(16px, 26rpx, 20px);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.get-code--disabled {
|
||||||
|
color: #9f968d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.placeholder {
|
||||||
|
color: #9f968d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-error,
|
||||||
|
.agreement-error {
|
||||||
|
display: block;
|
||||||
|
color: #b42318;
|
||||||
|
font-size: clamp(13px, 21rpx, 16px);
|
||||||
|
line-height: max(1.35em, clamp(17px, 32rpx, 22px));
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-error {
|
||||||
|
padding-top: 4rpx;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
.field-block--error .input-row {
|
||||||
|
border-bottom-color: #b42318;
|
||||||
|
}
|
||||||
|
|
||||||
|
.register-submit {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
min-height: var(--app-touch-min);
|
||||||
|
margin-top: 36rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.register-submit__skin {
|
||||||
|
grid-area: 1 / 1;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.register-submit__content {
|
||||||
|
z-index: 1;
|
||||||
|
grid-area: 1 / 1;
|
||||||
|
color: #fffaf0;
|
||||||
|
font-size: clamp(19px, 36rpx, 24px);
|
||||||
|
letter-spacing: 6rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agreement-area {
|
||||||
|
position: relative;
|
||||||
|
min-height: 118rpx;
|
||||||
|
margin-top: 18rpx;
|
||||||
|
color: #493323;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agreement-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: var(--app-touch-min);
|
||||||
|
font-size: clamp(12px, 22rpx, 14px);
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agreement-icon {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
width: 34rpx;
|
||||||
|
height: 34rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agreement-toggle {
|
||||||
|
display: inline-flex;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: var(--app-touch-min);
|
||||||
|
min-width: var(--app-touch-min);
|
||||||
|
min-height: var(--app-touch-min);
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0 -12rpx 0 0;
|
||||||
|
padding-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agreement-copy {
|
||||||
|
display: flex;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 0;
|
||||||
|
padding-top: 2rpx;
|
||||||
|
}
|
||||||
|
.agreement-copy text {
|
||||||
|
white-space: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agreement-link {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
min-height: var(--app-touch-min);
|
||||||
|
color: #a7160c;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.agreement-error {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agreement-area--error {
|
||||||
|
animation: agreement-shake 240ms ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes agreement-shake {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
33% {
|
||||||
|
transform: translateX(-8rpx);
|
||||||
|
}
|
||||||
|
66% {
|
||||||
|
transform: translateX(8rpx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-entry {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 82rpx;
|
||||||
|
margin-top: auto;
|
||||||
|
color: #493323;
|
||||||
|
font-size: clamp(16px, 28rpx, 20px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-entry__link {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
min-height: var(--app-touch-min);
|
||||||
|
margin-left: 8rpx;
|
||||||
|
color: #a7160c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tap-fade {
|
||||||
|
opacity: 0.62;
|
||||||
|
}
|
||||||
|
.button-hover {
|
||||||
|
opacity: 0.84;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,644 @@
|
|||||||
|
<template>
|
||||||
|
<AuthPageShell class="auth-page reset-page">
|
||||||
|
<view class="reset-content">
|
||||||
|
<view class="page-heading">
|
||||||
|
<button
|
||||||
|
class="auth-plain-button back-button"
|
||||||
|
aria-label="返回登录"
|
||||||
|
hover-class="tap-fade"
|
||||||
|
@click="requestBack"
|
||||||
|
>
|
||||||
|
<image
|
||||||
|
class="back-button__icon"
|
||||||
|
src="/static/assets/foundation/transparent/chevron-right.png"
|
||||||
|
mode="aspectFit"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
<text class="page-title">重设密码</text>
|
||||||
|
<text class="page-subtitle">验证手机号后设置新的登录密码</text>
|
||||||
|
<image
|
||||||
|
class="reset-divider"
|
||||||
|
src="/static/assets/modules/auth/transparent/title-divider.png"
|
||||||
|
mode="aspectFit"
|
||||||
|
/>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="form-content">
|
||||||
|
<view
|
||||||
|
class="field-block"
|
||||||
|
:class="{ 'field-block--error': fieldErrors.phone }"
|
||||||
|
>
|
||||||
|
<view class="input-row">
|
||||||
|
<label class="input-label" for="reset-password-phone"
|
||||||
|
><text class="required-mark">*</text>手机号</label
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
id="reset-password-phone"
|
||||||
|
v-model.trim="phone"
|
||||||
|
class="auth-input"
|
||||||
|
type="number"
|
||||||
|
maxlength="11"
|
||||||
|
:disabled="
|
||||||
|
sendingCode ||
|
||||||
|
submitting ||
|
||||||
|
(cooldownSeconds > 0 && phone.length > 0)
|
||||||
|
"
|
||||||
|
placeholder="请输入手机号"
|
||||||
|
placeholder-class="placeholder"
|
||||||
|
:aria-invalid="Boolean(fieldErrors.phone)"
|
||||||
|
:aria-describedby="
|
||||||
|
fieldErrors.phone ? 'reset-password-phone-error' : undefined
|
||||||
|
"
|
||||||
|
@input="handlePhoneInput"
|
||||||
|
/>
|
||||||
|
</view>
|
||||||
|
<text
|
||||||
|
v-if="fieldErrors.phone"
|
||||||
|
id="reset-password-phone-error"
|
||||||
|
class="field-error"
|
||||||
|
role="alert"
|
||||||
|
>{{ fieldErrors.phone }}</text
|
||||||
|
>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view
|
||||||
|
class="field-block"
|
||||||
|
:class="{ 'field-block--error': fieldErrors.verificationCode }"
|
||||||
|
>
|
||||||
|
<view class="input-row code-row">
|
||||||
|
<label class="input-label" for="reset-password-verification-code"
|
||||||
|
><text class="required-mark">*</text>验证码</label
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
id="reset-password-verification-code"
|
||||||
|
v-model.trim="verificationCode"
|
||||||
|
class="auth-input"
|
||||||
|
type="number"
|
||||||
|
maxlength="4"
|
||||||
|
:disabled="submitting"
|
||||||
|
placeholder="请输入验证码"
|
||||||
|
placeholder-class="placeholder"
|
||||||
|
:aria-invalid="Boolean(fieldErrors.verificationCode)"
|
||||||
|
:aria-describedby="
|
||||||
|
fieldErrors.verificationCode
|
||||||
|
? 'reset-password-verification-code-error'
|
||||||
|
: undefined
|
||||||
|
"
|
||||||
|
@input="clearFieldError('verificationCode')"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
class="auth-plain-button get-code"
|
||||||
|
:class="{
|
||||||
|
'get-code--disabled': sendingCode || cooldownSeconds > 0,
|
||||||
|
}"
|
||||||
|
:disabled="sendingCode || submitting || cooldownSeconds > 0"
|
||||||
|
hover-class="tap-fade"
|
||||||
|
@click="prepareGetCode"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
sendingCode
|
||||||
|
? "请求中…"
|
||||||
|
: cooldownSeconds > 0
|
||||||
|
? `${cooldownSeconds}s 后重发`
|
||||||
|
: "获取验证码"
|
||||||
|
}}
|
||||||
|
</button>
|
||||||
|
</view>
|
||||||
|
<text
|
||||||
|
v-if="fieldErrors.verificationCode"
|
||||||
|
id="reset-password-verification-code-error"
|
||||||
|
class="field-error"
|
||||||
|
role="alert"
|
||||||
|
>{{ fieldErrors.verificationCode }}</text
|
||||||
|
>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view
|
||||||
|
class="field-block"
|
||||||
|
:class="{ 'field-block--error': fieldErrors.password }"
|
||||||
|
>
|
||||||
|
<view class="input-row">
|
||||||
|
<label class="input-label" for="reset-password-new-password"
|
||||||
|
><text class="required-mark">*</text>新密码</label
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
id="reset-password-new-password"
|
||||||
|
v-model="password"
|
||||||
|
class="auth-input"
|
||||||
|
password
|
||||||
|
maxlength="32"
|
||||||
|
:disabled="submitting"
|
||||||
|
placeholder="请设置新密码"
|
||||||
|
placeholder-class="placeholder"
|
||||||
|
:aria-invalid="Boolean(fieldErrors.password)"
|
||||||
|
:aria-describedby="
|
||||||
|
fieldErrors.password ? 'reset-password-new-password-error' : undefined
|
||||||
|
"
|
||||||
|
@input="clearFieldError('password')"
|
||||||
|
/>
|
||||||
|
</view>
|
||||||
|
<text
|
||||||
|
v-if="fieldErrors.password"
|
||||||
|
id="reset-password-new-password-error"
|
||||||
|
class="field-error"
|
||||||
|
role="alert"
|
||||||
|
>{{ fieldErrors.password }}</text
|
||||||
|
>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view
|
||||||
|
class="field-block"
|
||||||
|
:class="{ 'field-block--error': fieldErrors.confirmPassword }"
|
||||||
|
>
|
||||||
|
<view class="input-row">
|
||||||
|
<label class="input-label input-label--confirm" for="reset-password-confirm-password"
|
||||||
|
><text class="required-mark">*</text>确认新密码</label
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
id="reset-password-confirm-password"
|
||||||
|
v-model="confirmPassword"
|
||||||
|
class="auth-input"
|
||||||
|
password
|
||||||
|
maxlength="32"
|
||||||
|
:disabled="submitting"
|
||||||
|
placeholder="请再次输入新密码"
|
||||||
|
placeholder-class="placeholder"
|
||||||
|
:aria-invalid="Boolean(fieldErrors.confirmPassword)"
|
||||||
|
:aria-describedby="
|
||||||
|
fieldErrors.confirmPassword
|
||||||
|
? 'reset-password-confirm-password-error'
|
||||||
|
: undefined
|
||||||
|
"
|
||||||
|
@input="clearFieldError('confirmPassword')"
|
||||||
|
/>
|
||||||
|
</view>
|
||||||
|
<text
|
||||||
|
v-if="fieldErrors.confirmPassword"
|
||||||
|
id="reset-password-confirm-password-error"
|
||||||
|
class="field-error"
|
||||||
|
role="alert"
|
||||||
|
>{{ fieldErrors.confirmPassword }}</text
|
||||||
|
>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="auth-plain-button reset-submit"
|
||||||
|
:disabled="submitting || sendingCode || tacVisible"
|
||||||
|
:aria-busy="submitting"
|
||||||
|
hover-class="button-hover"
|
||||||
|
@click="submitReset"
|
||||||
|
>
|
||||||
|
<image
|
||||||
|
class="reset-submit__skin"
|
||||||
|
src="/static/assets/foundation/transparent/scroll-primary.png"
|
||||||
|
mode="aspectFit"
|
||||||
|
/>
|
||||||
|
<text class="reset-submit__content">{{
|
||||||
|
submitting ? "提交中…" : "确认重设"
|
||||||
|
}}</text>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<text class="reset-tip">重设成功后,请使用新密码登录</text>
|
||||||
|
<view class="login-entry">
|
||||||
|
<text>想起密码了?</text>
|
||||||
|
<button
|
||||||
|
class="auth-plain-button login-entry__link"
|
||||||
|
:disabled="submitting || sendingCode || tacVisible"
|
||||||
|
hover-class="tap-fade"
|
||||||
|
@click="requestBack"
|
||||||
|
>
|
||||||
|
返回登录
|
||||||
|
</button>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<template #overlay>
|
||||||
|
<TacVerification
|
||||||
|
:visible="tacVisible"
|
||||||
|
:context="tacContext"
|
||||||
|
@success="completeTac"
|
||||||
|
@failure="handleTacFailure"
|
||||||
|
@error="handleTacError"
|
||||||
|
@cancel="closeTac"
|
||||||
|
/>
|
||||||
|
<AppDialog
|
||||||
|
:visible="discardVisible"
|
||||||
|
title="放弃重设密码?"
|
||||||
|
message="当前填写内容尚未保存,确认返回后将清空。"
|
||||||
|
confirm-text="放弃并返回"
|
||||||
|
cancel-text="继续填写"
|
||||||
|
show-cancel
|
||||||
|
compact-actions
|
||||||
|
:close-on-mask="false"
|
||||||
|
@confirm="confirmDiscard"
|
||||||
|
@cancel="cancelDiscard"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<AppDialog
|
||||||
|
:visible="successVisible"
|
||||||
|
title="密码已重设"
|
||||||
|
message="请返回登录页,使用新密码登录"
|
||||||
|
confirm-text="返回登录"
|
||||||
|
:close-on-mask="false"
|
||||||
|
@confirm="leaveResetSuccess"
|
||||||
|
>
|
||||||
|
<image
|
||||||
|
class="success-mark"
|
||||||
|
src="/static/assets/foundation/transparent/brand-seal.png"
|
||||||
|
mode="aspectFit"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
</AppDialog>
|
||||||
|
|
||||||
|
<AppToast :visible="feedbackVisible" :message="feedbackMessage" />
|
||||||
|
</template>
|
||||||
|
</AuthPageShell>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, ref } from "vue";
|
||||||
|
import { onBackPress, onShow, onUnload } from "@dcloudio/uni-app";
|
||||||
|
import AppDialog from "@/components/AppDialog.vue";
|
||||||
|
import AppToast from "@/components/AppToast.vue";
|
||||||
|
import AuthPageShell from "@/components/auth/PageShell.vue";
|
||||||
|
import TacVerification from "@/components/auth/TacVerification.vue";
|
||||||
|
import { useSmsVerification } from "@/composables/auth/use-sms-verification.js";
|
||||||
|
import {
|
||||||
|
createRequestController,
|
||||||
|
isRequestCancelled
|
||||||
|
} from "@/services/api/request-controller.js";
|
||||||
|
import { authApi } from "@/services/api/auth-service.js";
|
||||||
|
import {
|
||||||
|
AUTH_VERIFICATION_OPERATION,
|
||||||
|
isAuthPhone,
|
||||||
|
} from "@/utils/auth/verification.js";
|
||||||
|
import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
|
||||||
|
import { calcMD5 } from "@/utils/md5.js";
|
||||||
|
import { createNonIdempotentWriteGuard } from "@/utils/non-idempotent-write-guard.js";
|
||||||
|
import { handleBackPress, returnTo, runBackGuard } from "@/utils/navigation/gateway.js";
|
||||||
|
import {
|
||||||
|
PASSWORD_POLICY_MESSAGE,
|
||||||
|
validatePassword,
|
||||||
|
} from "@/utils/auth/password-policy.js";
|
||||||
|
|
||||||
|
const phone = ref("");
|
||||||
|
const verificationCode = ref("");
|
||||||
|
const password = ref("");
|
||||||
|
const confirmPassword = ref("");
|
||||||
|
const successVisible = ref(false);
|
||||||
|
const feedbackVisible = ref(false);
|
||||||
|
const feedbackMessage = ref("");
|
||||||
|
const discardVisible = ref(false);
|
||||||
|
const submitting = ref(false);
|
||||||
|
const fieldErrors = ref({
|
||||||
|
phone: "",
|
||||||
|
verificationCode: "",
|
||||||
|
password: "",
|
||||||
|
confirmPassword: "",
|
||||||
|
});
|
||||||
|
const isDirty = computed(
|
||||||
|
() =>
|
||||||
|
!successVisible.value &&
|
||||||
|
Boolean(
|
||||||
|
phone.value ||
|
||||||
|
verificationCode.value ||
|
||||||
|
password.value ||
|
||||||
|
confirmPassword.value,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
let feedbackTimer = null;
|
||||||
|
let pageActive = true;
|
||||||
|
const passwordResetSubmissionRequestController = createRequestController();
|
||||||
|
const passwordResetGuard = createNonIdempotentWriteGuard();
|
||||||
|
const discardConfirmation = createDiscardConfirmation((visible) => {
|
||||||
|
discardVisible.value = visible;
|
||||||
|
});
|
||||||
|
const requestDiscardConfirmation = discardConfirmation.request;
|
||||||
|
const confirmDiscard = discardConfirmation.confirm;
|
||||||
|
const cancelDiscard = discardConfirmation.cancel;
|
||||||
|
|
||||||
|
// 重设成功不产生可跨页消费的数据;只有远端 PUT 已成功后才显示该终态。
|
||||||
|
const leaveResetSuccess = () => returnTo("A01", {});
|
||||||
|
|
||||||
|
const requestBack = () => {
|
||||||
|
if (successVisible.value) return leaveResetSuccess();
|
||||||
|
return runBackGuard({
|
||||||
|
transientOpen: tacVisible.value || discardVisible.value,
|
||||||
|
submitting: submitting.value || sendingCode.value,
|
||||||
|
dirty: isDirty.value,
|
||||||
|
"close-transient": tacVisible.value ? closeTac : cancelDiscard,
|
||||||
|
"block-submitting": () => true,
|
||||||
|
"confirm-discard": requestDiscardConfirmation,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
onBackPress((event) => handleBackPress(event, requestBack));
|
||||||
|
|
||||||
|
onUnload(() => {
|
||||||
|
pageActive = false;
|
||||||
|
smsVerification.dispose();
|
||||||
|
passwordResetSubmissionRequestController.abort();
|
||||||
|
if (feedbackTimer) clearTimeout(feedbackTimer);
|
||||||
|
discardConfirmation.dispose();
|
||||||
|
});
|
||||||
|
onShow(() => smsVerification.syncCooldown());
|
||||||
|
|
||||||
|
const showFeedback = (message) => {
|
||||||
|
feedbackMessage.value = message;
|
||||||
|
feedbackVisible.value = true;
|
||||||
|
if (feedbackTimer) clearTimeout(feedbackTimer);
|
||||||
|
feedbackTimer = setTimeout(() => {
|
||||||
|
feedbackVisible.value = false;
|
||||||
|
feedbackTimer = null;
|
||||||
|
}, 2200);
|
||||||
|
};
|
||||||
|
|
||||||
|
const smsVerification = useSmsVerification({
|
||||||
|
operationCode: AUTH_VERIFICATION_OPERATION.FORGOT_PASSWORD,
|
||||||
|
requestIdPrefix: "forgot-password",
|
||||||
|
phone,
|
||||||
|
isActive: () => pageActive,
|
||||||
|
showFeedback,
|
||||||
|
});
|
||||||
|
const {
|
||||||
|
tacVisible,
|
||||||
|
tacContext,
|
||||||
|
sendingCode,
|
||||||
|
cooldownSeconds,
|
||||||
|
sentPhone,
|
||||||
|
closeTac,
|
||||||
|
completeTac,
|
||||||
|
handleTacFailure,
|
||||||
|
handleTacError,
|
||||||
|
} = smsVerification;
|
||||||
|
|
||||||
|
const clearFieldError = (field) => {
|
||||||
|
fieldErrors.value[field] = "";
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePhoneInput = () => {
|
||||||
|
clearFieldError("phone");
|
||||||
|
if (!sentPhone.value || phone.value === sentPhone.value) return;
|
||||||
|
verificationCode.value = "";
|
||||||
|
sentPhone.value = "";
|
||||||
|
};
|
||||||
|
|
||||||
|
const prepareGetCode = async () => {
|
||||||
|
if (sendingCode.value || submitting.value || cooldownSeconds.value > 0)
|
||||||
|
return;
|
||||||
|
if (!isAuthPhone(phone.value)) {
|
||||||
|
fieldErrors.value.phone = "请输入正确手机号";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
fieldErrors.value.phone = "";
|
||||||
|
return smsVerification.requestCode();
|
||||||
|
};
|
||||||
|
|
||||||
|
const validateForm = () => {
|
||||||
|
const nextErrors = {
|
||||||
|
phone: "",
|
||||||
|
verificationCode: "",
|
||||||
|
password: "",
|
||||||
|
confirmPassword: "",
|
||||||
|
};
|
||||||
|
if (!isAuthPhone(phone.value)) nextErrors.phone = "请输入正确手机号";
|
||||||
|
if (sentPhone.value !== phone.value)
|
||||||
|
nextErrors.verificationCode = "请先获取当前手机号的验证码";
|
||||||
|
else if (!/^\d{4}$/.test(verificationCode.value))
|
||||||
|
nextErrors.verificationCode = "请输入 4 位验证码";
|
||||||
|
const passwordResult = validatePassword(password.value);
|
||||||
|
if (!passwordResult.valid) nextErrors.password = PASSWORD_POLICY_MESSAGE;
|
||||||
|
if (!confirmPassword.value) nextErrors.confirmPassword = "请再次输入新密码";
|
||||||
|
else if (password.value !== confirmPassword.value)
|
||||||
|
nextErrors.confirmPassword = "两次密码输入不一致";
|
||||||
|
fieldErrors.value = nextErrors;
|
||||||
|
return !Object.values(nextErrors).some(Boolean);
|
||||||
|
};
|
||||||
|
|
||||||
|
const submitReset = async () => {
|
||||||
|
if (submitting.value || sendingCode.value) return;
|
||||||
|
if (!validateForm()) return;
|
||||||
|
const resetPayload = {
|
||||||
|
phone: phone.value,
|
||||||
|
passwordHash: calcMD5(password.value),
|
||||||
|
smsCode: verificationCode.value,
|
||||||
|
};
|
||||||
|
const resetAttempt = passwordResetGuard.begin(resetPayload);
|
||||||
|
if (resetAttempt === null) {
|
||||||
|
showFeedback(
|
||||||
|
"上次重设结果暂时无法确认,请先返回登录页尝试新密码,不要重复提交。",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
submitting.value = true;
|
||||||
|
try {
|
||||||
|
await authApi.resetPassword(
|
||||||
|
resetPayload,
|
||||||
|
{ requestController: passwordResetSubmissionRequestController },
|
||||||
|
);
|
||||||
|
if (!pageActive) return;
|
||||||
|
successVisible.value = true;
|
||||||
|
} catch (error) {
|
||||||
|
if (!pageActive) return;
|
||||||
|
if (passwordResetGuard.recordFailure(resetAttempt, error)) {
|
||||||
|
showFeedback(
|
||||||
|
"密码重设结果暂时无法确认,请先返回登录页尝试新密码,不要重复提交。",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!isRequestCancelled(error))
|
||||||
|
showFeedback(error.message || "密码重设失败,请稍后重试");
|
||||||
|
} finally {
|
||||||
|
if (pageActive) submitting.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.reset-content {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: flex-start;
|
||||||
|
box-sizing: border-box;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 480px;
|
||||||
|
min-width: 0;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: clamp(10px, 2.2vh, 20px) clamp(24px, 8.5vw, 44px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-heading {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
grid-template-rows: auto auto auto;
|
||||||
|
justify-items: center;
|
||||||
|
min-height: 154rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-button {
|
||||||
|
display: flex;
|
||||||
|
grid-row: 1 / -1;
|
||||||
|
grid-column: 1;
|
||||||
|
align-self: start;
|
||||||
|
justify-self: start;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: var(--app-touch-min);
|
||||||
|
height: var(--app-touch-min);
|
||||||
|
margin-top: 10rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-button__icon {
|
||||||
|
width: 42rpx;
|
||||||
|
height: 42rpx;
|
||||||
|
transform: scaleX(-1);
|
||||||
|
}
|
||||||
|
.page-title {
|
||||||
|
grid-column: 1;
|
||||||
|
grid-row: 1;
|
||||||
|
color: #9f170f;
|
||||||
|
font-size: clamp(29px, 58rpx, 36px);
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 7rpx;
|
||||||
|
}
|
||||||
|
.page-subtitle {
|
||||||
|
grid-column: 1;
|
||||||
|
grid-row: 2;
|
||||||
|
margin-top: 12rpx;
|
||||||
|
color: #806c58;
|
||||||
|
font-size: clamp(15px, 25rpx, 18px);
|
||||||
|
letter-spacing: 1rpx;
|
||||||
|
}
|
||||||
|
.reset-divider {
|
||||||
|
grid-column: 1;
|
||||||
|
grid-row: 3;
|
||||||
|
width: 300rpx;
|
||||||
|
height: 50rpx;
|
||||||
|
margin-top: 2rpx;
|
||||||
|
filter: brightness(0.68) saturate(1.5) contrast(1.2);
|
||||||
|
}
|
||||||
|
.form-content {
|
||||||
|
margin-top: 8rpx;
|
||||||
|
}
|
||||||
|
.field-block {
|
||||||
|
min-height: 114rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
min-height: var(--app-touch-min);
|
||||||
|
border-bottom: 1rpx solid #cfaa70;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-label {
|
||||||
|
flex: 0 0 162rpx;
|
||||||
|
color: #674b35;
|
||||||
|
font-size: clamp(17px, 30rpx, 22px);
|
||||||
|
}
|
||||||
|
.input-label--confirm {
|
||||||
|
flex-basis: 220rpx;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.required-mark {
|
||||||
|
display: inline-block;
|
||||||
|
margin-right: 4rpx;
|
||||||
|
color: #b42318;
|
||||||
|
font-size: clamp(16px, 26rpx, 20px);
|
||||||
|
line-height: 1;
|
||||||
|
transform: translateY(-3rpx);
|
||||||
|
}
|
||||||
|
.auth-input {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: var(--app-touch-min);
|
||||||
|
color: #493323;
|
||||||
|
font-size: clamp(17px, 30rpx, 22px);
|
||||||
|
}
|
||||||
|
.placeholder {
|
||||||
|
color: #9f968d;
|
||||||
|
}
|
||||||
|
.get-code {
|
||||||
|
display: flex;
|
||||||
|
flex: 0 0 176rpx;
|
||||||
|
box-sizing: border-box;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
padding: 0 8rpx;
|
||||||
|
border-left: 1rpx solid #d7bd94;
|
||||||
|
background: transparent !important;
|
||||||
|
color: #a7160c;
|
||||||
|
font-size: clamp(16px, 26rpx, 20px);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.get-code--disabled {
|
||||||
|
color: #9f968d;
|
||||||
|
}
|
||||||
|
.field-error {
|
||||||
|
display: block;
|
||||||
|
padding-top: 4rpx;
|
||||||
|
color: #b42318;
|
||||||
|
font-size: clamp(13px, 21rpx, 16px);
|
||||||
|
line-height: max(1.35em, clamp(16px, 28rpx, 20px));
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
.field-block--error .input-row {
|
||||||
|
border-bottom-color: #b42318;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reset-submit {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
min-height: var(--app-touch-min);
|
||||||
|
margin-top: 26rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reset-submit__skin {
|
||||||
|
grid-area: 1 / 1;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
.reset-submit__content {
|
||||||
|
z-index: 1;
|
||||||
|
grid-area: 1 / 1;
|
||||||
|
color: #fffaf0;
|
||||||
|
font-size: clamp(19px, 36rpx, 24px);
|
||||||
|
letter-spacing: 6rpx;
|
||||||
|
}
|
||||||
|
.reset-tip {
|
||||||
|
display: block;
|
||||||
|
margin-top: 22rpx;
|
||||||
|
color: #806c58;
|
||||||
|
font-size: clamp(15px, 24rpx, 18px);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.login-entry {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: var(--app-touch-min);
|
||||||
|
margin-top: auto;
|
||||||
|
color: #493323;
|
||||||
|
font-size: clamp(16px, 28rpx, 20px);
|
||||||
|
}
|
||||||
|
.login-entry__link {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
min-height: var(--app-touch-min);
|
||||||
|
margin-left: 8rpx;
|
||||||
|
color: #a7160c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.success-mark {
|
||||||
|
width: 82rpx;
|
||||||
|
height: 82rpx;
|
||||||
|
margin: 8rpx auto 12rpx;
|
||||||
|
}
|
||||||
|
.tap-fade,
|
||||||
|
.button-hover {
|
||||||
|
opacity: 0.72;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
<template>
|
|
||||||
<view class="page-shell editor-page">
|
|
||||||
<PageHeader title="发布家族动态" />
|
|
||||||
<view class="form-paper"><view class="section-title">记录此刻</view><text class="form-note">发布后,当前家谱的成员可以在家族圈查看。</text><textarea v-model="feedContent" maxlength="300" placeholder="写下通知、活动、家族故事或照片说明" placeholder-class="placeholder" /></view>
|
|
||||||
<view class="bottom-action"><button class="primary-button" :loading="isSubmitting" :disabled="isSubmitting" @click="submit">发布动态</button></view>
|
|
||||||
</view>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { ref } from 'vue'
|
|
||||||
import { onLoad } from '@dcloudio/uni-app'
|
|
||||||
import PageHeader from '@/components/PageHeader.vue'
|
|
||||||
import { appApi } from '@/utils/api.js'
|
|
||||||
import { genealogyContext } from '@/utils/genealogy-context.js'
|
|
||||||
|
|
||||||
const genealogyId = ref('')
|
|
||||||
const feedContent = ref('')
|
|
||||||
const isSubmitting = ref(false)
|
|
||||||
onLoad((query) => { genealogyId.value = query.genealogyId || genealogyContext.getCurrentGenealogyId() })
|
|
||||||
const submit = async () => {
|
|
||||||
if (!genealogyId.value) { uni.showToast({ title: '未找到当前家谱', icon: 'none' }); return }
|
|
||||||
if (!feedContent.value.trim()) { uni.showToast({ title: '请填写动态内容', icon: 'none' }); return }
|
|
||||||
isSubmitting.value = true
|
|
||||||
try { await appApi.createFeed(genealogyId.value, { feedType: 'text', feedContent: feedContent.value.trim() }); uni.navigateBack() } catch (error) { uni.showToast({ title: error.message || '发布失败,请稍后重试', icon: 'none' }) } finally { isSubmitting.value = false }
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped lang="scss">
|
|
||||||
.editor-page { padding-bottom: 136rpx; }
|
|
||||||
.form-paper { margin: 28rpx; padding: 34rpx 30rpx; border: 1rpx solid $gold-soft; border-radius: 22rpx; background: $paper-white; }
|
|
||||||
.form-note { display: block; margin-top: 14rpx; color: $ink-muted; font-size: 23rpx; line-height: 1.65; }
|
|
||||||
textarea { width: 100%; height: 360rpx; margin-top: 30rpx; color: $ink; font-size: 28rpx; line-height: 1.7; }
|
|
||||||
.placeholder { color: #b7aa97; }
|
|
||||||
.bottom-action { position: fixed; right: 0; bottom: 0; left: 0; padding: 20rpx 28rpx calc(20rpx + env(safe-area-inset-bottom)); background: #fffaf0; border-top: 1rpx solid $gold-soft; }
|
|
||||||
</style>
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
<template>
|
|
||||||
<view class="page-shell content-page">
|
|
||||||
<PageHeader :title="config.title" />
|
|
||||||
<view class="content-intro"><text>{{ config.copy }}</text></view>
|
|
||||||
<view class="content-list"><view v-for="item in items" :key="item.id" class="content-card paper-card"><text class="content-title">{{ item.title || item.articleTitle || item.albumName || item.ceremonyName || '家族记录' }}</text><text class="content-summary">{{ item.summary || item.content || item.description || item.feedContent || '内容待补充' }}</text><text class="content-date">{{ item.date || item.createTime || '' }}</text></view><view v-if="!items.length && !loadError" class="empty-copy">暂无{{ config.title }}</view></view>
|
|
||||||
<text v-if="loadError" class="error-copy">{{ loadError }}</text>
|
|
||||||
</view>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { computed, ref } from 'vue'
|
|
||||||
import { onLoad } from '@dcloudio/uni-app'
|
|
||||||
import PageHeader from '@/components/PageHeader.vue'
|
|
||||||
import { appApi } from '@/utils/api.js'
|
|
||||||
import { genealogyContext } from '@/utils/genealogy-context.js'
|
|
||||||
|
|
||||||
const type = ref('article')
|
|
||||||
const items = ref([])
|
|
||||||
const loadError = ref('')
|
|
||||||
const definitions = { article: { title: '谱文', copy: '记录家族历史、家训与珍贵文字。' }, album: { title: '相册', copy: '珍存家人、故土与仪式的影像。' }, ceremony: { title: '祭祀', copy: '查看家族祭祀安排与纪念活动。' }, record: { title: '族务', copy: '共同记录成长、备忘与家族事务。' } }
|
|
||||||
const config = computed(() => definitions[type.value] || definitions.article)
|
|
||||||
|
|
||||||
onLoad(async (query) => {
|
|
||||||
type.value = definitions[query.type] ? query.type : 'article'
|
|
||||||
const genealogyId = query.genealogyId || genealogyContext.getCurrentGenealogyId()
|
|
||||||
if (!genealogyId) { loadError.value = '请先选择一个家谱。'; return }
|
|
||||||
genealogyContext.setCurrentGenealogyId(genealogyId)
|
|
||||||
try { items.value = await appApi.getContent(type.value, genealogyId) } catch (error) { loadError.value = error.message || '内容加载失败。' }
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped lang="scss">
|
|
||||||
.content-page { padding-bottom: 36rpx; }
|
|
||||||
.content-intro { padding: 28rpx 30rpx; background: #efe3cd; color: $ink-muted; font-size: 24rpx; }
|
|
||||||
.content-list { display: flex; flex-direction: column; gap: 18rpx; margin: 26rpx 28rpx; }
|
|
||||||
.content-card { padding: 28rpx; }
|
|
||||||
.content-title { display: block; color: $ink; font-size: 31rpx; font-weight: 700; }
|
|
||||||
.content-summary { display: block; margin-top: 14rpx; color: $ink-muted; font-size: 25rpx; line-height: 1.65; }
|
|
||||||
.content-date { display: block; margin-top: 16rpx; color: $gold; font-size: 22rpx; }
|
|
||||||
.empty-copy { padding: 80rpx 0; color: $ink-muted; font-size: 26rpx; text-align: center; }
|
|
||||||
.error-copy { display: block; margin: 20rpx 28rpx; color: $brand-red; font-size: 24rpx; }
|
|
||||||
</style>
|
|
||||||
@@ -0,0 +1,446 @@
|
|||||||
|
<template>
|
||||||
|
<view class="media-upload-page" :class="`media-state--${pageState}`">
|
||||||
|
<ModulePageBackground module="family" />
|
||||||
|
<view class="media-upload-header"
|
||||||
|
><PageHeader title="添加照片" custom-back @back="requestBack"
|
||||||
|
/></view>
|
||||||
|
<view class="media-upload-content">
|
||||||
|
<view v-if="pageState === 'form'" class="media-panel">
|
||||||
|
<text class="media-panel__title">添加一张家族照片</text>
|
||||||
|
<text class="media-panel__note"
|
||||||
|
>请先选择图片。上传成功后,照片才会保存到相册。</text
|
||||||
|
>
|
||||||
|
|
||||||
|
<view class="media-field media-field--upload">
|
||||||
|
<text class="media-field__label"
|
||||||
|
><text class="required-mark">*</text>照片</text
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
class="upload-button"
|
||||||
|
:disabled="uploading || submitting"
|
||||||
|
@click="selectPhoto"
|
||||||
|
>
|
||||||
|
{{ uploading ? "上传中…" : receipt ? "重新选择图片" : "选择图片" }}
|
||||||
|
</button>
|
||||||
|
<text v-if="receipt" class="upload-receipt"
|
||||||
|
>已上传:{{ receipt.fileName || "图片" }}</text
|
||||||
|
>
|
||||||
|
</view>
|
||||||
|
<text v-if="uploadError" class="field-error">{{ uploadError }}</text>
|
||||||
|
|
||||||
|
<view class="media-field">
|
||||||
|
<text class="media-field__label">照片标题</text>
|
||||||
|
<input
|
||||||
|
v-model="form.photoTitle"
|
||||||
|
maxlength="60"
|
||||||
|
placeholder="例如:祖宅合影"
|
||||||
|
placeholder-class="placeholder"
|
||||||
|
@input="submitError = ''"
|
||||||
|
/>
|
||||||
|
</view>
|
||||||
|
<view class="media-field media-field--textarea">
|
||||||
|
<text class="media-field__label">照片说明</text>
|
||||||
|
<textarea
|
||||||
|
v-model="form.photoDesc"
|
||||||
|
maxlength="500"
|
||||||
|
auto-height
|
||||||
|
placeholder="补充照片中的人物、场景或故事"
|
||||||
|
placeholder-class="placeholder"
|
||||||
|
@input="submitError = ''"
|
||||||
|
/>
|
||||||
|
</view>
|
||||||
|
<view class="media-field">
|
||||||
|
<text class="media-field__label">拍摄人</text>
|
||||||
|
<input
|
||||||
|
v-model="form.photographer"
|
||||||
|
maxlength="30"
|
||||||
|
placeholder="请输入拍摄人"
|
||||||
|
placeholder-class="placeholder"
|
||||||
|
@input="submitError = ''"
|
||||||
|
/>
|
||||||
|
</view>
|
||||||
|
<view class="media-field media-field--picker">
|
||||||
|
<text class="media-field__label">拍摄日期</text>
|
||||||
|
<picker mode="date" :value="form.shootDate" @change="selectShootDate">
|
||||||
|
<view :class="{ placeholder: !form.shootDate }">{{
|
||||||
|
form.shootDate || "请选择"
|
||||||
|
}}</view>
|
||||||
|
</picker>
|
||||||
|
</view>
|
||||||
|
<view class="media-field media-field--picker">
|
||||||
|
<text class="media-field__label">拍摄时间</text>
|
||||||
|
<picker
|
||||||
|
mode="time"
|
||||||
|
:value="form.shootClock"
|
||||||
|
@change="selectShootClock"
|
||||||
|
>
|
||||||
|
<view :class="{ placeholder: !form.shootClock }">{{
|
||||||
|
form.shootClock || "请选择"
|
||||||
|
}}</view>
|
||||||
|
</picker>
|
||||||
|
</view>
|
||||||
|
<text v-if="submitError" class="field-error">{{ submitError }}</text>
|
||||||
|
<AppButton
|
||||||
|
block
|
||||||
|
:disabled="uploading || submitting"
|
||||||
|
:label="submitting ? '正在保存…' : '保存照片'"
|
||||||
|
@click="submitPhoto"
|
||||||
|
/>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view v-else class="media-state-card">
|
||||||
|
<text class="media-state-card__title">{{ stateCopy.title }}</text>
|
||||||
|
<text class="media-state-card__copy">{{ stateCopy.copy }}</text>
|
||||||
|
<AppButton block :label="stateCopy.action" @click="handleStateAction" />
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<AppDialog
|
||||||
|
:visible="discardVisible"
|
||||||
|
title="放弃照片草稿?"
|
||||||
|
message="照片还没有保存,返回后将不会保留。"
|
||||||
|
confirm-text="放弃并返回"
|
||||||
|
cancel-text="继续填写"
|
||||||
|
show-cancel
|
||||||
|
:close-on-mask="false"
|
||||||
|
@confirm="confirmation.confirm"
|
||||||
|
@cancel="confirmation.cancel"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, reactive, ref } from "vue";
|
||||||
|
import { onBackPress, onLoad, onUnload } from "@dcloudio/uni-app";
|
||||||
|
import AppButton from "@/components/AppButton.vue";
|
||||||
|
import AppDialog from "@/components/AppDialog.vue";
|
||||||
|
import ModulePageBackground from "@/components/ModulePageBackground.vue";
|
||||||
|
import PageHeader from "@/components/PageHeader.vue";
|
||||||
|
import {
|
||||||
|
createRequestController,
|
||||||
|
isRequestCancelled
|
||||||
|
} from "@/services/api/request-controller.js";
|
||||||
|
import { familyMediaApi } from "@/services/api/family-media-service.js";
|
||||||
|
import { getRequestErrorMessage } from "@/services/api/request-error-message.js";
|
||||||
|
import {
|
||||||
|
isImagePickCancelled,
|
||||||
|
pickAndUploadImage,
|
||||||
|
} from "@/utils/media-upload.js";
|
||||||
|
import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
|
||||||
|
import { createNonIdempotentWriteGuard } from "@/utils/non-idempotent-write-guard.js";
|
||||||
|
import {
|
||||||
|
goBack,
|
||||||
|
handleBackPress,
|
||||||
|
returnTo,
|
||||||
|
runBackGuard,
|
||||||
|
} from "@/utils/navigation/gateway.js";
|
||||||
|
|
||||||
|
const genealogyId = ref("");
|
||||||
|
const albumId = ref("");
|
||||||
|
const pageState = ref("form");
|
||||||
|
const receipt = ref(null);
|
||||||
|
const uploadError = ref("");
|
||||||
|
const submitError = ref("");
|
||||||
|
const uploading = ref(false);
|
||||||
|
const submitting = ref(false);
|
||||||
|
const form = reactive({
|
||||||
|
photoTitle: "",
|
||||||
|
photoDesc: "",
|
||||||
|
photographer: "",
|
||||||
|
shootDate: "",
|
||||||
|
shootClock: "",
|
||||||
|
});
|
||||||
|
const photoUploadController = createRequestController();
|
||||||
|
const photoSaveController = createRequestController();
|
||||||
|
const photoCreateGuard = createNonIdempotentWriteGuard();
|
||||||
|
let pageActive = true;
|
||||||
|
const discardVisible = ref(false);
|
||||||
|
const confirmation = createDiscardConfirmation((visible) => {
|
||||||
|
discardVisible.value = visible;
|
||||||
|
});
|
||||||
|
|
||||||
|
const hasValidContext = computed(
|
||||||
|
() =>
|
||||||
|
/^[1-9]\d*$/.test(genealogyId.value) && /^[1-9]\d*$/.test(albumId.value),
|
||||||
|
);
|
||||||
|
const isDirty = computed(
|
||||||
|
() =>
|
||||||
|
Boolean(receipt.value) || Object.values(form).some((value) => value.trim()),
|
||||||
|
);
|
||||||
|
const shootTime = computed(() =>
|
||||||
|
form.shootDate ? `${form.shootDate} ${form.shootClock || "00:00"}:00` : "",
|
||||||
|
);
|
||||||
|
const stateCopy = computed(() =>
|
||||||
|
pageState.value === "success"
|
||||||
|
? {
|
||||||
|
title: "照片已添加",
|
||||||
|
copy: "照片已保存到相册。",
|
||||||
|
action: "返回相册",
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
title: "暂时无法添加照片",
|
||||||
|
copy: "未找到家谱或相册信息,请返回后重新进入。",
|
||||||
|
action: "返回上一页",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
onLoad((query) => {
|
||||||
|
genealogyId.value = String(query?.genealogyId || "");
|
||||||
|
albumId.value = String(query?.albumId || "");
|
||||||
|
if (!hasValidContext.value) pageState.value = "invalid";
|
||||||
|
});
|
||||||
|
|
||||||
|
const selectPhoto = async () => {
|
||||||
|
if (uploading.value || submitting.value) return;
|
||||||
|
uploading.value = true;
|
||||||
|
uploadError.value = "";
|
||||||
|
try {
|
||||||
|
const uploadedPhoto = await pickAndUploadImage({
|
||||||
|
requestController: photoUploadController,
|
||||||
|
});
|
||||||
|
if (!pageActive) return;
|
||||||
|
receipt.value = uploadedPhoto;
|
||||||
|
} catch (error) {
|
||||||
|
if (pageActive && !isImagePickCancelled(error) && !isRequestCancelled(error)) {
|
||||||
|
uploadError.value = getRequestErrorMessage(error, "图片上传失败,请稍后重试");
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (pageActive) uploading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const selectShootDate = (event) => {
|
||||||
|
form.shootDate = event.detail.value || "";
|
||||||
|
submitError.value = "";
|
||||||
|
};
|
||||||
|
const selectShootClock = (event) => {
|
||||||
|
form.shootClock = event.detail.value || "";
|
||||||
|
submitError.value = "";
|
||||||
|
};
|
||||||
|
|
||||||
|
const submitPhoto = async () => {
|
||||||
|
if (uploading.value || submitting.value || !hasValidContext.value) return;
|
||||||
|
if (!receipt.value) {
|
||||||
|
submitError.value = "请先选择并上传照片";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { shootDate, shootClock, ...photoForm } = form;
|
||||||
|
const payload = {
|
||||||
|
...photoForm,
|
||||||
|
shootTime: shootTime.value,
|
||||||
|
ossId: receipt.value.ossId,
|
||||||
|
};
|
||||||
|
const createAttempt = photoCreateGuard.begin(payload);
|
||||||
|
if (createAttempt === null) {
|
||||||
|
submitError.value =
|
||||||
|
"上次保存结果暂时无法确认,请先返回相册检查,避免重复添加。";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
submitting.value = true;
|
||||||
|
submitError.value = "";
|
||||||
|
try {
|
||||||
|
await familyMediaApi.createAlbumPhoto(
|
||||||
|
genealogyId.value,
|
||||||
|
albumId.value,
|
||||||
|
payload,
|
||||||
|
{ requestController: photoSaveController },
|
||||||
|
);
|
||||||
|
if (!pageActive) return;
|
||||||
|
pageState.value = "success";
|
||||||
|
} catch (error) {
|
||||||
|
if (!pageActive) return;
|
||||||
|
if (photoCreateGuard.recordFailure(createAttempt, error)) {
|
||||||
|
submitError.value =
|
||||||
|
"保存结果暂时无法确认,请先返回相册检查,避免重复添加。";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!isRequestCancelled(error))
|
||||||
|
submitError.value = getRequestErrorMessage(error, "照片保存失败,请稍后重试");
|
||||||
|
} finally {
|
||||||
|
if (pageActive) submitting.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const returnToAlbum = () =>
|
||||||
|
hasValidContext.value
|
||||||
|
? returnTo("F08", {
|
||||||
|
genealogyId: genealogyId.value,
|
||||||
|
albumId: albumId.value,
|
||||||
|
})
|
||||||
|
: goBack();
|
||||||
|
const requestBack = () =>
|
||||||
|
runBackGuard({
|
||||||
|
transientOpen: discardVisible.value,
|
||||||
|
dirty: isDirty.value,
|
||||||
|
submitting: uploading.value || submitting.value,
|
||||||
|
"close-transient": confirmation.cancel,
|
||||||
|
"block-submitting": () => true,
|
||||||
|
"confirm-discard": confirmation.request,
|
||||||
|
});
|
||||||
|
const handleStateAction = () =>
|
||||||
|
pageState.value === "success" ? returnToAlbum() : goBack();
|
||||||
|
onBackPress((event) => handleBackPress(event, requestBack));
|
||||||
|
onUnload(() => {
|
||||||
|
pageActive = false;
|
||||||
|
photoUploadController.abort();
|
||||||
|
photoSaveController.abort();
|
||||||
|
confirmation.dispose();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
@import "../../styles/adaptive-frame-profiles.scss";
|
||||||
|
|
||||||
|
.media-upload-page {
|
||||||
|
display: flex;
|
||||||
|
min-height: 100vh;
|
||||||
|
flex-direction: column;
|
||||||
|
background: $paper;
|
||||||
|
}
|
||||||
|
.media-upload-header,
|
||||||
|
.media-upload-content {
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
.media-upload-content {
|
||||||
|
flex: 1;
|
||||||
|
padding: 20rpx 24rpx calc(48rpx + env(safe-area-inset-bottom));
|
||||||
|
}
|
||||||
|
.media-panel,
|
||||||
|
.media-state-card {
|
||||||
|
@include adaptive-family-panel;
|
||||||
|
box-sizing: border-box;
|
||||||
|
background-color: rgba($paper, 0.82);
|
||||||
|
}
|
||||||
|
.media-panel {
|
||||||
|
padding: 38rpx 32rpx 42rpx;
|
||||||
|
}
|
||||||
|
.media-panel__title,
|
||||||
|
.media-panel__note,
|
||||||
|
.media-field__label,
|
||||||
|
.field-error {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.media-panel__title {
|
||||||
|
color: $ink;
|
||||||
|
font-family: STKaiti, KaiTi, serif;
|
||||||
|
font-size: clamp(19px, 36rpx, 24px);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.media-panel__note {
|
||||||
|
margin-top: 10rpx;
|
||||||
|
color: $ink-muted;
|
||||||
|
font-size: clamp(14px, 23rpx, 17px);
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
.media-field {
|
||||||
|
margin-top: 20rpx;
|
||||||
|
}
|
||||||
|
.media-field__label {
|
||||||
|
margin: 0 6rpx 8rpx;
|
||||||
|
color: $ink;
|
||||||
|
font-size: clamp(15px, 25rpx, 18px);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.required-mark {
|
||||||
|
display: inline-block;
|
||||||
|
margin-right: 4rpx;
|
||||||
|
color: $brand-red;
|
||||||
|
font-size: clamp(16px, 26rpx, 20px);
|
||||||
|
line-height: 1;
|
||||||
|
transform: translateY(-3rpx);
|
||||||
|
}
|
||||||
|
.media-field input,
|
||||||
|
.media-field textarea {
|
||||||
|
@include adaptive-family-field;
|
||||||
|
box-sizing: border-box;
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
color: $ink;
|
||||||
|
font-size: clamp(15px, 24rpx, 18px);
|
||||||
|
}
|
||||||
|
.media-field input {
|
||||||
|
min-height: 80rpx;
|
||||||
|
padding: 0 22rpx;
|
||||||
|
}
|
||||||
|
.media-field--picker picker {
|
||||||
|
display: block;
|
||||||
|
min-height: 80rpx;
|
||||||
|
}
|
||||||
|
.media-field--picker picker > view {
|
||||||
|
display: flex;
|
||||||
|
min-height: 80rpx;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0 22rpx;
|
||||||
|
border: 1rpx solid rgba(128, 89, 49, 0.34);
|
||||||
|
border-radius: 12rpx;
|
||||||
|
background: rgba(255, 252, 245, 0.7);
|
||||||
|
color: $ink;
|
||||||
|
font-size: clamp(15px, 24rpx, 18px);
|
||||||
|
}
|
||||||
|
.media-field textarea {
|
||||||
|
min-height: 116rpx;
|
||||||
|
padding: 18rpx 22rpx;
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
.placeholder {
|
||||||
|
color: #9e8e79;
|
||||||
|
}
|
||||||
|
.media-field--upload {
|
||||||
|
display: grid;
|
||||||
|
gap: 12rpx;
|
||||||
|
padding: 18rpx 20rpx;
|
||||||
|
border: 1rpx solid rgba(128, 89, 49, 0.34);
|
||||||
|
border-radius: 12rpx;
|
||||||
|
background: rgba(255, 252, 245, 0.7);
|
||||||
|
}
|
||||||
|
.media-field--upload .media-field__label {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.upload-button {
|
||||||
|
justify-self: start;
|
||||||
|
min-height: 88rpx;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0 20rpx;
|
||||||
|
border: 1rpx solid rgba(159, 23, 15, 0.42);
|
||||||
|
border-radius: 8rpx;
|
||||||
|
background: transparent;
|
||||||
|
color: $brand-red;
|
||||||
|
font-size: clamp(14px, 22rpx, 17px);
|
||||||
|
}
|
||||||
|
.upload-receipt {
|
||||||
|
color: $ink-muted;
|
||||||
|
font-size: clamp(13px, 21rpx, 16px);
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
.field-error {
|
||||||
|
margin-top: 10rpx;
|
||||||
|
color: $brand-red;
|
||||||
|
font-size: clamp(14px, 22rpx, 17px);
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
.media-panel .app-button {
|
||||||
|
margin-top: 28rpx;
|
||||||
|
}
|
||||||
|
.media-state-card {
|
||||||
|
min-height: 410rpx;
|
||||||
|
padding: 98rpx 46rpx 58rpx;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.media-state-card text {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.media-state-card__title {
|
||||||
|
color: $ink;
|
||||||
|
font-family: STKaiti, KaiTi, serif;
|
||||||
|
font-size: clamp(19px, 35rpx, 24px);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.media-state-card__copy {
|
||||||
|
margin-top: 16rpx;
|
||||||
|
color: $ink-muted;
|
||||||
|
font-size: clamp(14px, 23rpx, 17px);
|
||||||
|
line-height: 1.65;
|
||||||
|
}
|
||||||
|
.media-state-card .app-button {
|
||||||
|
margin-top: 30rpx;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,568 @@
|
|||||||
|
<template>
|
||||||
|
<view class="album-detail-page">
|
||||||
|
<ModulePageBackground module="family" />
|
||||||
|
<view class="album-detail-header">
|
||||||
|
<PageHeader
|
||||||
|
title="相册详情"
|
||||||
|
:action="headerAction"
|
||||||
|
custom-back
|
||||||
|
@back="requestBack"
|
||||||
|
@action="handleHeaderAction"
|
||||||
|
/>
|
||||||
|
</view>
|
||||||
|
<view class="album-detail-content">
|
||||||
|
<view v-if="!valid" class="album-state-card">
|
||||||
|
<text>暂时无法打开相册</text>
|
||||||
|
<AppButton block label="返回相册列表" @click="returnToAlbums" />
|
||||||
|
</view>
|
||||||
|
<view v-else-if="albumPhotoListState === 'loading'" class="album-state-card">
|
||||||
|
<AppLoading text="正在读取相册照片" />
|
||||||
|
</view>
|
||||||
|
<view v-else-if="albumPhotoListState === 'error'" class="album-state-card">
|
||||||
|
<text>暂时无法读取相册照片</text>
|
||||||
|
<AppButton block type="secondary" label="重新加载" @click="loadPhotos" />
|
||||||
|
</view>
|
||||||
|
<view v-else-if="albumPhotoListState === 'empty'" class="album-state-card">
|
||||||
|
<text>还没有照片</text>
|
||||||
|
<text>添加照片后,页面会显示最新相册内容。</text>
|
||||||
|
<AppButton block label="添加照片" @click="addPhoto" />
|
||||||
|
</view>
|
||||||
|
<view v-else class="photo-list">
|
||||||
|
<view v-if="deletablePhotos.length" class="photo-management">
|
||||||
|
<template v-if="selectionMode">
|
||||||
|
<view class="photo-management__summary">
|
||||||
|
<text>已选择 {{ selectedPhotoIds.length }} 张</text>
|
||||||
|
<text>仅可选择有删除权限的照片</text>
|
||||||
|
</view>
|
||||||
|
<view class="photo-management__actions">
|
||||||
|
<AppButton
|
||||||
|
compact
|
||||||
|
type="secondary"
|
||||||
|
:disabled="batchDeleting"
|
||||||
|
:label="allDeletableSelected ? '取消全选' : '全选'"
|
||||||
|
@click="toggleAllDeletablePhotos"
|
||||||
|
/>
|
||||||
|
<AppButton
|
||||||
|
compact
|
||||||
|
:disabled="!selectedPhotoIds.length || batchDeleting"
|
||||||
|
:label="batchDeleteButtonLabel"
|
||||||
|
@click="requestBatchDelete"
|
||||||
|
/>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<view class="photo-management__summary">
|
||||||
|
<text>批量管理照片</text>
|
||||||
|
<text>可一次选择多张照片删除</text>
|
||||||
|
</view>
|
||||||
|
<AppButton compact type="secondary" label="管理照片" @click="enterSelectionMode" />
|
||||||
|
</template>
|
||||||
|
</view>
|
||||||
|
<text v-if="deleteNotice" class="photo-list__notice" role="status">{{ deleteNotice }}</text>
|
||||||
|
<text v-if="deleteError" class="photo-list__error">{{ deleteError }}</text>
|
||||||
|
<view
|
||||||
|
v-for="item in photos"
|
||||||
|
:key="item.id"
|
||||||
|
class="photo-card"
|
||||||
|
:class="{ 'photo-card--selected': isPhotoSelected(item) }"
|
||||||
|
>
|
||||||
|
<view
|
||||||
|
v-if="selectionMode && item.canDelete"
|
||||||
|
class="photo-card__selection"
|
||||||
|
role="checkbox"
|
||||||
|
:aria-checked="isPhotoSelected(item)"
|
||||||
|
:aria-label="`${isPhotoSelected(item) ? '取消选择' : '选择'}照片:${item.title}`"
|
||||||
|
@click="togglePhotoSelection(item)"
|
||||||
|
>
|
||||||
|
<text>{{ isPhotoSelected(item) ? "已选择" : "选择" }}</text>
|
||||||
|
</view>
|
||||||
|
<image
|
||||||
|
class="photo-card__image"
|
||||||
|
:src="item.photoFile.accessUrl"
|
||||||
|
mode="widthFix"
|
||||||
|
:role="selectionMode && item.canDelete ? 'checkbox' : 'button'"
|
||||||
|
:aria-checked="selectionMode && item.canDelete ? isPhotoSelected(item) : undefined"
|
||||||
|
:aria-label="selectionMode && item.canDelete ? `${isPhotoSelected(item) ? '取消选择' : '选择'}照片:${item.title}` : `查看大图:${item.title}`"
|
||||||
|
@click="handlePhotoClick(item)"
|
||||||
|
/>
|
||||||
|
<text class="photo-card__title">{{ item.title }}</text>
|
||||||
|
<text v-if="item.description" class="photo-card__copy">{{ item.description }}</text>
|
||||||
|
<text v-if="item.meta" class="photo-card__meta">{{ item.meta }}</text>
|
||||||
|
<view v-if="item.canDelete && !selectionMode" class="photo-card__actions">
|
||||||
|
<AppButton compact type="secondary" label="删除照片" @click.stop="requestDeletePhoto(item)" />
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<AppDialog
|
||||||
|
:visible="deleteConfirmationVisible"
|
||||||
|
:close-on-mask="false"
|
||||||
|
eyebrow="删除确认"
|
||||||
|
title="将这张照片移至回收站?"
|
||||||
|
message="移入回收站后不再展示,家谱管理员可在保留期内恢复。"
|
||||||
|
confirm-text="移至回收站"
|
||||||
|
cancel-text="保留照片"
|
||||||
|
show-cancel
|
||||||
|
@confirm="deletePhoto"
|
||||||
|
@cancel="closeDeleteConfirmation"
|
||||||
|
/>
|
||||||
|
<AppDialog
|
||||||
|
:visible="batchDeleteConfirmationVisible"
|
||||||
|
:close-on-mask="false"
|
||||||
|
eyebrow="批量移除"
|
||||||
|
title="将选中的照片移至回收站?"
|
||||||
|
:message="batchDeleteConfirmationMessage"
|
||||||
|
:confirm-text="batchDeleting ? '正在移除' : '移至回收站'"
|
||||||
|
cancel-text="继续选择"
|
||||||
|
show-cancel
|
||||||
|
@confirm="deleteSelectedPhotos"
|
||||||
|
@cancel="closeBatchDeleteConfirmation"
|
||||||
|
/>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, ref } from "vue";
|
||||||
|
import { onBackPress, onLoad, onShow, onUnload } from "@dcloudio/uni-app";
|
||||||
|
import AppButton from "@/components/AppButton.vue";
|
||||||
|
import AppDialog from "@/components/AppDialog.vue";
|
||||||
|
import AppLoading from "@/components/AppLoading.vue";
|
||||||
|
import ModulePageBackground from "@/components/ModulePageBackground.vue";
|
||||||
|
import PageHeader from "@/components/PageHeader.vue";
|
||||||
|
import {
|
||||||
|
createRequestController,
|
||||||
|
isRequestCancelled
|
||||||
|
} from "@/services/api/request-controller.js";
|
||||||
|
import { familyMediaApi } from "@/services/api/family-media-service.js";
|
||||||
|
import { getRequestErrorMessage } from "@/services/api/request-error-message.js";
|
||||||
|
import { goBack, handleBackPress, openPage, returnTo } from "@/utils/navigation/gateway.js";
|
||||||
|
|
||||||
|
const genealogyId = ref("");
|
||||||
|
const albumId = ref("");
|
||||||
|
const photos = ref([]);
|
||||||
|
const albumPhotoListState = ref("loading");
|
||||||
|
const deleteTarget = ref(null);
|
||||||
|
const deleteConfirmationVisible = ref(false);
|
||||||
|
const deletingPhotoId = ref("");
|
||||||
|
const deleteError = ref("");
|
||||||
|
const deleteNotice = ref("");
|
||||||
|
const selectionMode = ref(false);
|
||||||
|
const selectedPhotoIds = ref([]);
|
||||||
|
const batchDeleteConfirmationVisible = ref(false);
|
||||||
|
const batchDeleting = ref(false);
|
||||||
|
const albumPhotoListController = createRequestController();
|
||||||
|
const albumPhotoDeleteController = createRequestController();
|
||||||
|
let isPageActive = true;
|
||||||
|
const valid = computed(
|
||||||
|
() =>
|
||||||
|
/^[1-9]\d*$/.test(genealogyId.value) && /^[1-9]\d*$/.test(albumId.value),
|
||||||
|
);
|
||||||
|
const headerAction = computed(() => {
|
||||||
|
if (!valid.value) return "";
|
||||||
|
return selectionMode.value ? "完成" : "添加";
|
||||||
|
});
|
||||||
|
const deletablePhotos = computed(() => photos.value.filter((photo) => photo.canDelete));
|
||||||
|
const allDeletableSelected = computed(
|
||||||
|
() =>
|
||||||
|
deletablePhotos.value.length > 0 &&
|
||||||
|
deletablePhotos.value.every((photo) => selectedPhotoIds.value.includes(photo.id)),
|
||||||
|
);
|
||||||
|
const batchDeleteConfirmationMessage = computed(() =>
|
||||||
|
`将选中的 ${selectedPhotoIds.value.length} 张照片移入回收站;家谱管理员可在保留期内恢复。`,
|
||||||
|
);
|
||||||
|
const batchDeleteButtonLabel = computed(() =>
|
||||||
|
batchDeleting.value
|
||||||
|
? "正在删除"
|
||||||
|
: selectedPhotoIds.value.length
|
||||||
|
? `删除 ${selectedPhotoIds.value.length}`
|
||||||
|
: "删除",
|
||||||
|
);
|
||||||
|
const deleteInProgress = computed(
|
||||||
|
() => Boolean(deletingPhotoId.value) || batchDeleting.value,
|
||||||
|
);
|
||||||
|
const resetPhotoSelection = () => {
|
||||||
|
selectionMode.value = false;
|
||||||
|
selectedPhotoIds.value = [];
|
||||||
|
batchDeleteConfirmationVisible.value = false;
|
||||||
|
};
|
||||||
|
const loadPhotos = async () => {
|
||||||
|
if (!valid.value) return;
|
||||||
|
albumPhotoListController.abort();
|
||||||
|
albumPhotoListState.value = "loading";
|
||||||
|
try {
|
||||||
|
const albumPhotos = await familyMediaApi.getAlbumPhotos(genealogyId.value, albumId.value, {
|
||||||
|
requestController: albumPhotoListController,
|
||||||
|
});
|
||||||
|
if (!isPageActive) return;
|
||||||
|
photos.value = albumPhotos.map((photo) => ({
|
||||||
|
...photo,
|
||||||
|
meta: [photo.photographer, photo.shootTime].filter(Boolean).join(" · "),
|
||||||
|
}));
|
||||||
|
resetPhotoSelection();
|
||||||
|
albumPhotoListState.value = photos.value.length ? "ready" : "empty";
|
||||||
|
} catch (error) {
|
||||||
|
if (!isPageActive || isRequestCancelled(error)) return;
|
||||||
|
albumPhotoListState.value = "error";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const returnToAlbums = () =>
|
||||||
|
/^[1-9]\d*$/.test(genealogyId.value)
|
||||||
|
? returnTo("F07", { genealogyId: genealogyId.value })
|
||||||
|
: goBack();
|
||||||
|
const addPhoto = () =>
|
||||||
|
valid.value
|
||||||
|
? openPage(
|
||||||
|
"F09",
|
||||||
|
{ genealogyId: genealogyId.value, albumId: albumId.value },
|
||||||
|
"F08",
|
||||||
|
)
|
||||||
|
: Promise.resolve(false);
|
||||||
|
const handleHeaderAction = () => {
|
||||||
|
if (selectionMode.value) {
|
||||||
|
resetPhotoSelection();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
addPhoto();
|
||||||
|
};
|
||||||
|
const enterSelectionMode = () => {
|
||||||
|
if (!deletablePhotos.value.length || deleteInProgress.value) return;
|
||||||
|
deleteError.value = "";
|
||||||
|
deleteNotice.value = "";
|
||||||
|
selectedPhotoIds.value = [];
|
||||||
|
selectionMode.value = true;
|
||||||
|
};
|
||||||
|
const isPhotoSelected = (photo) => selectedPhotoIds.value.includes(photo?.id);
|
||||||
|
const togglePhotoSelection = (photo) => {
|
||||||
|
if (!selectionMode.value || !photo?.canDelete || batchDeleting.value) return;
|
||||||
|
selectedPhotoIds.value = isPhotoSelected(photo)
|
||||||
|
? selectedPhotoIds.value.filter((photoId) => photoId !== photo.id)
|
||||||
|
: [...selectedPhotoIds.value, photo.id];
|
||||||
|
deleteError.value = "";
|
||||||
|
deleteNotice.value = "";
|
||||||
|
};
|
||||||
|
const toggleAllDeletablePhotos = () => {
|
||||||
|
if (!selectionMode.value || batchDeleting.value) return;
|
||||||
|
selectedPhotoIds.value = allDeletableSelected.value
|
||||||
|
? []
|
||||||
|
: deletablePhotos.value.map((photo) => photo.id);
|
||||||
|
deleteError.value = "";
|
||||||
|
deleteNotice.value = "";
|
||||||
|
};
|
||||||
|
const previewPhoto = (photo) => {
|
||||||
|
const urls = photos.value.map((item) => item.photoFile?.accessUrl).filter(Boolean);
|
||||||
|
const current = photo?.photoFile?.accessUrl;
|
||||||
|
if (!current || !urls.length || typeof uni?.previewImage !== "function") return;
|
||||||
|
uni.previewImage({ current, urls });
|
||||||
|
};
|
||||||
|
const handlePhotoClick = (photo) => {
|
||||||
|
if (selectionMode.value && photo?.canDelete) {
|
||||||
|
togglePhotoSelection(photo);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
previewPhoto(photo);
|
||||||
|
};
|
||||||
|
const requestDeletePhoto = (photo) => {
|
||||||
|
if (!photo?.canDelete || deleteInProgress.value) return;
|
||||||
|
deleteError.value = "";
|
||||||
|
deleteNotice.value = "";
|
||||||
|
deleteTarget.value = photo;
|
||||||
|
deleteConfirmationVisible.value = true;
|
||||||
|
};
|
||||||
|
const closeDeleteConfirmation = () => {
|
||||||
|
if (!deletingPhotoId.value) {
|
||||||
|
deleteConfirmationVisible.value = false;
|
||||||
|
deleteTarget.value = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const requestBack = () => {
|
||||||
|
if (deleteInProgress.value) return true;
|
||||||
|
if (batchDeleteConfirmationVisible.value) {
|
||||||
|
closeBatchDeleteConfirmation();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (deleteConfirmationVisible.value) {
|
||||||
|
closeDeleteConfirmation();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (selectionMode.value) {
|
||||||
|
resetPhotoSelection();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return returnToAlbums();
|
||||||
|
};
|
||||||
|
const deletePhoto = async () => {
|
||||||
|
const photo = deleteTarget.value;
|
||||||
|
if (!photo?.canDelete || deletingPhotoId.value) return;
|
||||||
|
deletingPhotoId.value = photo.id;
|
||||||
|
deleteError.value = "";
|
||||||
|
try {
|
||||||
|
await familyMediaApi.deleteAlbumPhoto(genealogyId.value, albumId.value, photo.id, {
|
||||||
|
requestController: albumPhotoDeleteController,
|
||||||
|
});
|
||||||
|
if (!isPageActive) return;
|
||||||
|
deleteConfirmationVisible.value = false;
|
||||||
|
deleteTarget.value = null;
|
||||||
|
await loadPhotos();
|
||||||
|
} catch (error) {
|
||||||
|
if (!isPageActive || isRequestCancelled(error)) return;
|
||||||
|
deleteError.value = getRequestErrorMessage(error, "照片删除失败,请稍后重试。");
|
||||||
|
deleteConfirmationVisible.value = false;
|
||||||
|
} finally {
|
||||||
|
if (isPageActive) deletingPhotoId.value = "";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const requestBatchDelete = () => {
|
||||||
|
if (!selectionMode.value || !selectedPhotoIds.value.length || deleteInProgress.value) return;
|
||||||
|
deleteError.value = "";
|
||||||
|
deleteNotice.value = "";
|
||||||
|
batchDeleteConfirmationVisible.value = true;
|
||||||
|
};
|
||||||
|
const closeBatchDeleteConfirmation = () => {
|
||||||
|
if (!batchDeleting.value) batchDeleteConfirmationVisible.value = false;
|
||||||
|
};
|
||||||
|
const deleteSelectedPhotos = async () => {
|
||||||
|
if (!selectionMode.value || !selectedPhotoIds.value.length || deleteInProgress.value) return;
|
||||||
|
const photoIds = selectedPhotoIds.value.slice();
|
||||||
|
batchDeleting.value = true;
|
||||||
|
deleteError.value = "";
|
||||||
|
deleteNotice.value = "";
|
||||||
|
let deletedCount = 0;
|
||||||
|
let failedRequest = null;
|
||||||
|
try {
|
||||||
|
for (const photoId of photoIds) {
|
||||||
|
const photo = photos.value.find((item) => item.id === photoId);
|
||||||
|
if (!photo?.canDelete) continue;
|
||||||
|
try {
|
||||||
|
await familyMediaApi.deleteAlbumPhoto(
|
||||||
|
genealogyId.value,
|
||||||
|
albumId.value,
|
||||||
|
photoId,
|
||||||
|
{ requestController: albumPhotoDeleteController },
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
failedRequest = error;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (!isPageActive) return;
|
||||||
|
deletedCount += 1;
|
||||||
|
photos.value = photos.value.filter((item) => item.id !== photoId);
|
||||||
|
selectedPhotoIds.value = selectedPhotoIds.value.filter(
|
||||||
|
(selectedPhotoId) => selectedPhotoId !== photoId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!isPageActive) return;
|
||||||
|
batchDeleteConfirmationVisible.value = false;
|
||||||
|
if (failedRequest && !isRequestCancelled(failedRequest)) {
|
||||||
|
const failureCopy = getRequestErrorMessage(
|
||||||
|
failedRequest,
|
||||||
|
"剩余照片删除失败,请稍后重试。",
|
||||||
|
);
|
||||||
|
deleteError.value = deletedCount
|
||||||
|
? `已删除 ${deletedCount} 张;${failureCopy}`
|
||||||
|
: failureCopy;
|
||||||
|
} else if (deletedCount) {
|
||||||
|
deleteNotice.value = `已删除 ${deletedCount} 张照片。`;
|
||||||
|
}
|
||||||
|
if (!photos.value.length) {
|
||||||
|
albumPhotoListState.value = "empty";
|
||||||
|
resetPhotoSelection();
|
||||||
|
} else if (!selectedPhotoIds.value.length) {
|
||||||
|
selectionMode.value = false;
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (isPageActive) batchDeleting.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
onLoad((query) => {
|
||||||
|
genealogyId.value = String(query?.genealogyId || "");
|
||||||
|
albumId.value = String(query?.albumId || "");
|
||||||
|
if (valid.value) loadPhotos();
|
||||||
|
});
|
||||||
|
onShow(() => {
|
||||||
|
if (valid.value && albumPhotoListState.value !== "loading") loadPhotos();
|
||||||
|
});
|
||||||
|
onUnload(() => {
|
||||||
|
isPageActive = false;
|
||||||
|
albumPhotoListController.abort();
|
||||||
|
albumPhotoDeleteController.abort();
|
||||||
|
});
|
||||||
|
onBackPress((event) => handleBackPress(event, requestBack));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
@import "../../styles/adaptive-frame-profiles.scss";
|
||||||
|
.album-detail-page {
|
||||||
|
display: flex;
|
||||||
|
min-height: 100vh;
|
||||||
|
flex-direction: column;
|
||||||
|
background: $paper;
|
||||||
|
}
|
||||||
|
.album-detail-header,
|
||||||
|
.album-detail-content {
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
.album-detail-content {
|
||||||
|
padding: 22rpx 24rpx calc(48rpx + env(safe-area-inset-bottom));
|
||||||
|
}
|
||||||
|
.album-state-card,
|
||||||
|
.photo-card {
|
||||||
|
@include adaptive-family-content;
|
||||||
|
background-color: rgba($paper, 0.82);
|
||||||
|
}
|
||||||
|
.album-state-card {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 340rpx;
|
||||||
|
padding: 84rpx 44rpx 56rpx;
|
||||||
|
box-sizing: border-box;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.album-state-card text {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.album-state-card text:first-child {
|
||||||
|
color: $ink;
|
||||||
|
font-family: STKaiti, KaiTi, serif;
|
||||||
|
font-size: clamp(19px, 36rpx, 24px);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.album-state-card text:nth-child(2) {
|
||||||
|
margin-top: 10rpx;
|
||||||
|
color: $ink-muted;
|
||||||
|
font-size: clamp(14px, 23rpx, 17px);
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
.album-state-card .app-button {
|
||||||
|
margin-top: 34rpx;
|
||||||
|
}
|
||||||
|
.photo-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 20rpx;
|
||||||
|
}
|
||||||
|
.photo-management {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 18rpx;
|
||||||
|
padding: 22rpx 24rpx;
|
||||||
|
border: 1rpx solid rgba(128, 89, 49, 0.28);
|
||||||
|
border-radius: 12rpx;
|
||||||
|
background: rgba($paper, 0.9);
|
||||||
|
}
|
||||||
|
.photo-management__summary {
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.photo-management__summary text {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.photo-management__summary text:first-child {
|
||||||
|
color: $ink;
|
||||||
|
font-size: clamp(15px, 24rpx, 18px);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.photo-management__summary text:last-child {
|
||||||
|
margin-top: 6rpx;
|
||||||
|
color: $ink-muted;
|
||||||
|
font-size: clamp(13px, 21rpx, 16px);
|
||||||
|
}
|
||||||
|
.photo-management__actions {
|
||||||
|
display: flex;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
gap: 12rpx;
|
||||||
|
}
|
||||||
|
.photo-card {
|
||||||
|
position: relative;
|
||||||
|
padding: 28rpx 32rpx;
|
||||||
|
}
|
||||||
|
.photo-card--selected {
|
||||||
|
border-color: rgba($brand-red, 0.62);
|
||||||
|
box-shadow: inset 0 0 0 2rpx rgba($brand-red, 0.12);
|
||||||
|
}
|
||||||
|
.photo-card__selection {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 2;
|
||||||
|
top: 42rpx;
|
||||||
|
right: 46rpx;
|
||||||
|
min-width: 104rpx;
|
||||||
|
min-height: 64rpx;
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 0 18rpx;
|
||||||
|
border: 1rpx solid rgba($brand-red, 0.48);
|
||||||
|
border-radius: 32rpx;
|
||||||
|
background: rgba($paper, 0.94);
|
||||||
|
color: $brand-red;
|
||||||
|
text-align: center;
|
||||||
|
line-height: 62rpx;
|
||||||
|
}
|
||||||
|
.photo-card--selected .photo-card__selection {
|
||||||
|
background: $brand-red;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.photo-list__notice,
|
||||||
|
.photo-list__error {
|
||||||
|
display: block;
|
||||||
|
font-size: clamp(13px, 21rpx, 16px);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
.photo-list__notice {
|
||||||
|
color: #426b58;
|
||||||
|
}
|
||||||
|
.photo-list__error {
|
||||||
|
color: $brand-red;
|
||||||
|
}
|
||||||
|
.photo-card__image {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
margin-bottom: 20rpx;
|
||||||
|
border-radius: 10rpx;
|
||||||
|
background: rgba(128, 89, 49, 0.12);
|
||||||
|
}
|
||||||
|
.photo-card__actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-top: 16rpx;
|
||||||
|
}
|
||||||
|
.photo-card__title,
|
||||||
|
.photo-card__copy,
|
||||||
|
.photo-card__meta {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.photo-card__title {
|
||||||
|
color: $ink;
|
||||||
|
font-family: STKaiti, KaiTi, serif;
|
||||||
|
font-size: clamp(17px, 30rpx, 22px);
|
||||||
|
font-weight: 700;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
.photo-card__copy,
|
||||||
|
.photo-card__meta {
|
||||||
|
margin-top: 8rpx;
|
||||||
|
color: $ink-muted;
|
||||||
|
font-size: clamp(14px, 22rpx, 17px);
|
||||||
|
line-height: 1.5;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
.photo-card .photo-card__selection text {
|
||||||
|
display: inline;
|
||||||
|
margin: 0;
|
||||||
|
color: inherit;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: clamp(13px, 21rpx, 16px);
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: inherit;
|
||||||
|
}
|
||||||
|
@media (max-width: 380px) {
|
||||||
|
.photo-management {
|
||||||
|
align-items: stretch;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.photo-management__actions,
|
||||||
|
.photo-management > .app-button {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.photo-management__actions .app-button {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,529 @@
|
|||||||
|
<template>
|
||||||
|
<view class="album-list-page">
|
||||||
|
<ModulePageBackground module="family" />
|
||||||
|
<view class="album-list-header"
|
||||||
|
><PageHeader
|
||||||
|
title="家族相册"
|
||||||
|
:action="hasValidContext ? '新建' : ''"
|
||||||
|
custom-back
|
||||||
|
@back="requestBack"
|
||||||
|
@action="openCreateDialog"
|
||||||
|
/></view>
|
||||||
|
<view class="album-list-content">
|
||||||
|
<view v-if="!hasValidContext" class="album-state-card"
|
||||||
|
><text>暂时无法打开相册</text><text>未找到家谱信息,请返回后重新进入。</text
|
||||||
|
><AppButton block label="返回上一页" @click="goBack"
|
||||||
|
/></view>
|
||||||
|
<view v-else-if="listState === 'loading'" class="album-state-card"
|
||||||
|
><AppLoading text="正在读取家族相册"
|
||||||
|
/></view>
|
||||||
|
<view v-else-if="listState === 'error'" class="album-state-card"
|
||||||
|
><text>暂时无法读取家族相册</text><text>{{ listError }}</text
|
||||||
|
><AppButton block type="secondary" label="重新加载" @click="loadAlbums"
|
||||||
|
/></view>
|
||||||
|
<view v-else-if="listState === 'empty'" class="album-state-card"
|
||||||
|
><text>还没有相册</text><text>新建第一本相册,整理家族影像。</text
|
||||||
|
><AppButton block label="新建相册" @click="openCreateDialog"
|
||||||
|
/></view>
|
||||||
|
<view v-else class="album-list">
|
||||||
|
<text v-if="deleteError" class="album-list__error">{{ deleteError }}</text>
|
||||||
|
<view
|
||||||
|
v-for="item in albums"
|
||||||
|
:key="item.id"
|
||||||
|
class="album-card"
|
||||||
|
@click="openAlbum(item)"
|
||||||
|
>
|
||||||
|
<image
|
||||||
|
v-if="item.coverFile?.accessUrl"
|
||||||
|
class="album-card__cover"
|
||||||
|
:src="item.coverFile.accessUrl"
|
||||||
|
mode="aspectFill"
|
||||||
|
/>
|
||||||
|
<text>{{ item.name }}</text>
|
||||||
|
<text v-if="item.description">{{ item.description }}</text>
|
||||||
|
<text>{{ item.photoCount }} 张照片</text>
|
||||||
|
<text v-if="item.createTime">创建于 {{ formatMinuteTimestamp(item.createTime) }}</text>
|
||||||
|
<view
|
||||||
|
v-if="item.canEdit || item.canDelete"
|
||||||
|
class="album-card__actions"
|
||||||
|
@click.stop
|
||||||
|
>
|
||||||
|
<AppButton
|
||||||
|
v-if="item.canEdit"
|
||||||
|
compact
|
||||||
|
type="secondary"
|
||||||
|
label="编辑相册"
|
||||||
|
@click="openEditDialog(item)"
|
||||||
|
/>
|
||||||
|
<AppButton
|
||||||
|
v-if="item.canDelete"
|
||||||
|
compact
|
||||||
|
type="secondary"
|
||||||
|
label="删除相册"
|
||||||
|
@click="requestDeleteAlbum(item)"
|
||||||
|
/>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<AppDialog
|
||||||
|
:visible="dialogVisible"
|
||||||
|
:eyebrow="editingAlbum ? '编辑相册' : '新建相册'"
|
||||||
|
:title="editingAlbum ? '更新这本家族相册' : '为家人整理一段影像'"
|
||||||
|
:confirm-text="isSubmitting ? '正在提交' : editingAlbum ? '保存修改' : '提交相册'"
|
||||||
|
cancel-text="取消"
|
||||||
|
show-cancel
|
||||||
|
:close-on-mask="false"
|
||||||
|
@confirm="submitAlbum"
|
||||||
|
@cancel="closeCreateDialog"
|
||||||
|
>
|
||||||
|
<view class="album-dialog-field"
|
||||||
|
><text><text class="required-mark">*</text>相册名称</text
|
||||||
|
><input
|
||||||
|
v-model="form.albumName"
|
||||||
|
placeholder="例如:春节团圆"
|
||||||
|
@input="submitError = ''"
|
||||||
|
/></view>
|
||||||
|
<view class="album-dialog-field"
|
||||||
|
><text>相册说明</text
|
||||||
|
><textarea
|
||||||
|
v-model="form.albumDesc"
|
||||||
|
auto-height
|
||||||
|
placeholder="介绍这组影像"
|
||||||
|
@input="submitError = ''"
|
||||||
|
/>
|
||||||
|
</view>
|
||||||
|
<view class="album-dialog-field album-dialog-field--cover"
|
||||||
|
><view
|
||||||
|
><text>封面图片</text
|
||||||
|
><text class="album-field-hint"
|
||||||
|
>图片上传成功后,会作为相册封面保存。</text
|
||||||
|
></view
|
||||||
|
><button
|
||||||
|
class="upload-button"
|
||||||
|
:disabled="uploading || isSubmitting"
|
||||||
|
@click="uploadCover"
|
||||||
|
>
|
||||||
|
{{ uploading ? "上传中…" : "选择图片" }}</button
|
||||||
|
><text v-if="coverFileName" class="upload-receipt"
|
||||||
|
>已上传:{{ coverFileName }}</text
|
||||||
|
><text v-if="uploadError" class="album-field-error">{{
|
||||||
|
uploadError
|
||||||
|
}}</text></view
|
||||||
|
>
|
||||||
|
<text v-if="submitError" class="album-field-error">{{
|
||||||
|
submitError
|
||||||
|
}}</text>
|
||||||
|
</AppDialog>
|
||||||
|
<AppDialog
|
||||||
|
:visible="deleteConfirmationVisible"
|
||||||
|
:close-on-mask="false"
|
||||||
|
eyebrow="删除确认"
|
||||||
|
title="将这个相册移至回收站?"
|
||||||
|
message="相册和其中照片将不再展示,家谱管理员可在保留期内恢复。"
|
||||||
|
confirm-text="移至回收站"
|
||||||
|
cancel-text="保留相册"
|
||||||
|
show-cancel
|
||||||
|
@confirm="deleteAlbum"
|
||||||
|
@cancel="closeDeleteConfirmation"
|
||||||
|
/>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, reactive, ref } from "vue";
|
||||||
|
import { onBackPress, onLoad, onShow, onUnload } from "@dcloudio/uni-app";
|
||||||
|
import AppButton from "@/components/AppButton.vue";
|
||||||
|
import AppDialog from "@/components/AppDialog.vue";
|
||||||
|
import AppLoading from "@/components/AppLoading.vue";
|
||||||
|
import ModulePageBackground from "@/components/ModulePageBackground.vue";
|
||||||
|
import PageHeader from "@/components/PageHeader.vue";
|
||||||
|
import {
|
||||||
|
createRequestController,
|
||||||
|
isRequestCancelled
|
||||||
|
} from "@/services/api/request-controller.js";
|
||||||
|
import { familyMediaApi } from "@/services/api/family-media-service.js";
|
||||||
|
import { getRequestErrorMessage } from "@/services/api/request-error-message.js";
|
||||||
|
import { formatMinuteTimestamp } from "@/utils/display-time.js";
|
||||||
|
import {
|
||||||
|
isImagePickCancelled,
|
||||||
|
pickAndUploadImage,
|
||||||
|
} from "@/utils/media-upload.js";
|
||||||
|
import { createNonIdempotentWriteGuard } from "@/utils/non-idempotent-write-guard.js";
|
||||||
|
import {
|
||||||
|
goBack,
|
||||||
|
handleBackPress,
|
||||||
|
openPage,
|
||||||
|
runBackGuard,
|
||||||
|
} from "@/utils/navigation/gateway.js";
|
||||||
|
|
||||||
|
const genealogyId = ref("");
|
||||||
|
const albums = ref([]);
|
||||||
|
const listState = ref("loading");
|
||||||
|
const listError = ref("");
|
||||||
|
const dialogVisible = ref(false);
|
||||||
|
const form = reactive({ albumName: "", albumDesc: "" });
|
||||||
|
const editingAlbum = ref(null);
|
||||||
|
const coverOssId = ref(null);
|
||||||
|
const coverFileName = ref("");
|
||||||
|
const submitError = ref("");
|
||||||
|
const uploadError = ref("");
|
||||||
|
const uploading = ref(false);
|
||||||
|
const isSubmitting = ref(false);
|
||||||
|
const deleteTarget = ref(null);
|
||||||
|
const deleteConfirmationVisible = ref(false);
|
||||||
|
const deletingAlbumId = ref("");
|
||||||
|
const deleteError = ref("");
|
||||||
|
const albumListController = createRequestController();
|
||||||
|
const albumCoverUploadController = createRequestController();
|
||||||
|
const albumSaveController = createRequestController();
|
||||||
|
const albumDeleteController = createRequestController();
|
||||||
|
const albumCreateGuard = createNonIdempotentWriteGuard();
|
||||||
|
let isPageActive = true;
|
||||||
|
const hasValidContext = computed(() => /^[1-9]\d*$/.test(genealogyId.value));
|
||||||
|
const loadAlbums = async () => {
|
||||||
|
if (!hasValidContext.value) return;
|
||||||
|
albumListController.abort();
|
||||||
|
listState.value = "loading";
|
||||||
|
listError.value = "";
|
||||||
|
try {
|
||||||
|
const rows = await familyMediaApi.getAlbums(genealogyId.value, {
|
||||||
|
requestController: albumListController,
|
||||||
|
});
|
||||||
|
if (!isPageActive) return;
|
||||||
|
albums.value = rows;
|
||||||
|
listState.value = albums.value.length ? "list" : "empty";
|
||||||
|
} catch (error) {
|
||||||
|
if (!isPageActive || isRequestCancelled(error)) return;
|
||||||
|
listState.value = "error";
|
||||||
|
listError.value = getRequestErrorMessage(error, "请稍后重试。");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const resetDraft = () => {
|
||||||
|
Object.assign(form, { albumName: "", albumDesc: "" });
|
||||||
|
coverOssId.value = null;
|
||||||
|
coverFileName.value = "";
|
||||||
|
submitError.value = "";
|
||||||
|
uploadError.value = "";
|
||||||
|
editingAlbum.value = null;
|
||||||
|
};
|
||||||
|
const openCreateDialog = () => {
|
||||||
|
if (!hasValidContext.value || isSubmitting.value || uploading.value) return;
|
||||||
|
resetDraft();
|
||||||
|
dialogVisible.value = true;
|
||||||
|
};
|
||||||
|
const openEditDialog = (album) => {
|
||||||
|
if (
|
||||||
|
!album?.canEdit ||
|
||||||
|
isSubmitting.value ||
|
||||||
|
uploading.value ||
|
||||||
|
!Number.isSafeInteger(album.sortOrder) ||
|
||||||
|
!["0", "1"].includes(album.status)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resetDraft();
|
||||||
|
Object.assign(form, { albumName: album.name, albumDesc: album.description });
|
||||||
|
coverOssId.value = album.coverFile?.ossId || null;
|
||||||
|
coverFileName.value = album.coverFile
|
||||||
|
? album.coverFile.fileName || "当前封面图片"
|
||||||
|
: "";
|
||||||
|
editingAlbum.value = {
|
||||||
|
id: album.id,
|
||||||
|
sortOrder: album.sortOrder,
|
||||||
|
status: album.status,
|
||||||
|
};
|
||||||
|
dialogVisible.value = true;
|
||||||
|
};
|
||||||
|
const closeCreateDialog = () => {
|
||||||
|
if (!isSubmitting.value && !uploading.value) dialogVisible.value = false;
|
||||||
|
};
|
||||||
|
const uploadCover = async () => {
|
||||||
|
if (uploading.value || isSubmitting.value) return;
|
||||||
|
uploading.value = true;
|
||||||
|
uploadError.value = "";
|
||||||
|
try {
|
||||||
|
const receipt = await pickAndUploadImage({
|
||||||
|
requestController: albumCoverUploadController,
|
||||||
|
});
|
||||||
|
if (!isPageActive) return;
|
||||||
|
coverOssId.value = receipt.ossId;
|
||||||
|
coverFileName.value = receipt.fileName || "封面图片";
|
||||||
|
} catch (error) {
|
||||||
|
if (!isPageActive) return;
|
||||||
|
if (!isImagePickCancelled(error) && !isRequestCancelled(error))
|
||||||
|
uploadError.value = getRequestErrorMessage(error, "封面图片上传失败,请稍后重试。");
|
||||||
|
} finally {
|
||||||
|
if (isPageActive) uploading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const submitAlbum = async () => {
|
||||||
|
if (isSubmitting.value || uploading.value) return;
|
||||||
|
if (!form.albumName.trim()) {
|
||||||
|
submitError.value = "请填写相册名称";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const payload = {
|
||||||
|
...form,
|
||||||
|
coverOssId: coverOssId.value,
|
||||||
|
...(editingAlbum.value
|
||||||
|
? {
|
||||||
|
sortOrder: editingAlbum.value.sortOrder,
|
||||||
|
status: editingAlbum.value.status,
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
};
|
||||||
|
const createAttempt = editingAlbum.value ? null : albumCreateGuard.begin(payload);
|
||||||
|
if (!editingAlbum.value && createAttempt === null) {
|
||||||
|
submitError.value =
|
||||||
|
"上次提交结果暂时无法确认,请先关闭窗口并检查相册列表,避免重复创建。";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isSubmitting.value = true;
|
||||||
|
submitError.value = "";
|
||||||
|
try {
|
||||||
|
if (editingAlbum.value) {
|
||||||
|
await familyMediaApi.updateAlbum(
|
||||||
|
genealogyId.value,
|
||||||
|
editingAlbum.value.id,
|
||||||
|
payload,
|
||||||
|
{ requestController: albumSaveController },
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await familyMediaApi.createAlbum(genealogyId.value, payload, {
|
||||||
|
requestController: albumSaveController,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!isPageActive) return;
|
||||||
|
dialogVisible.value = false;
|
||||||
|
await loadAlbums();
|
||||||
|
} catch (error) {
|
||||||
|
if (!isPageActive) return;
|
||||||
|
if (!editingAlbum.value && albumCreateGuard.recordFailure(createAttempt, error)) {
|
||||||
|
submitError.value =
|
||||||
|
"提交结果暂时无法确认,请先关闭窗口并检查相册列表,避免重复创建。";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!isRequestCancelled(error))
|
||||||
|
submitError.value = getRequestErrorMessage(error, "相册提交失败,请稍后重试。");
|
||||||
|
} finally {
|
||||||
|
if (isPageActive) isSubmitting.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const openAlbum = (item) =>
|
||||||
|
openPage("F08", { genealogyId: genealogyId.value, albumId: item.id }, "F07");
|
||||||
|
const requestDeleteAlbum = (album) => {
|
||||||
|
if (!album?.canDelete || deletingAlbumId.value) return;
|
||||||
|
deleteError.value = "";
|
||||||
|
deleteTarget.value = album;
|
||||||
|
deleteConfirmationVisible.value = true;
|
||||||
|
};
|
||||||
|
const closeDeleteConfirmation = () => {
|
||||||
|
if (!deletingAlbumId.value) {
|
||||||
|
deleteConfirmationVisible.value = false;
|
||||||
|
deleteTarget.value = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const deleteAlbum = async () => {
|
||||||
|
const album = deleteTarget.value;
|
||||||
|
if (!album?.canDelete || deletingAlbumId.value) return;
|
||||||
|
deletingAlbumId.value = album.id;
|
||||||
|
deleteError.value = "";
|
||||||
|
try {
|
||||||
|
await familyMediaApi.deleteAlbum(genealogyId.value, album.id, {
|
||||||
|
requestController: albumDeleteController,
|
||||||
|
});
|
||||||
|
if (!isPageActive) return;
|
||||||
|
deleteConfirmationVisible.value = false;
|
||||||
|
deleteTarget.value = null;
|
||||||
|
await loadAlbums();
|
||||||
|
} catch (error) {
|
||||||
|
if (!isPageActive || isRequestCancelled(error)) return;
|
||||||
|
deleteError.value = getRequestErrorMessage(error, "相册删除失败,请稍后重试。");
|
||||||
|
deleteConfirmationVisible.value = false;
|
||||||
|
} finally {
|
||||||
|
if (isPageActive) deletingAlbumId.value = "";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const requestBack = () =>
|
||||||
|
runBackGuard({
|
||||||
|
transientOpen: dialogVisible.value,
|
||||||
|
submitting: isSubmitting.value || uploading.value,
|
||||||
|
"close-transient": closeCreateDialog,
|
||||||
|
"block-submitting": () => true,
|
||||||
|
});
|
||||||
|
onLoad((query) => {
|
||||||
|
genealogyId.value = String(query?.genealogyId || "");
|
||||||
|
if (hasValidContext.value) loadAlbums();
|
||||||
|
else listState.value = "invalid";
|
||||||
|
});
|
||||||
|
onShow(() => {
|
||||||
|
if (
|
||||||
|
hasValidContext.value &&
|
||||||
|
!dialogVisible.value &&
|
||||||
|
listState.value !== "loading"
|
||||||
|
)
|
||||||
|
loadAlbums();
|
||||||
|
});
|
||||||
|
onBackPress((event) => handleBackPress(event, requestBack));
|
||||||
|
onUnload(() => {
|
||||||
|
isPageActive = false;
|
||||||
|
albumListController.abort();
|
||||||
|
albumCoverUploadController.abort();
|
||||||
|
albumSaveController.abort();
|
||||||
|
albumDeleteController.abort();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
@import "../../styles/adaptive-frame-profiles.scss";
|
||||||
|
.album-list-page {
|
||||||
|
display: flex;
|
||||||
|
min-height: 100vh;
|
||||||
|
flex-direction: column;
|
||||||
|
background: $paper;
|
||||||
|
}
|
||||||
|
.album-list-header,
|
||||||
|
.album-list-content {
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
.album-list-content {
|
||||||
|
padding: 18rpx 24rpx calc(72rpx + env(safe-area-inset-bottom));
|
||||||
|
}
|
||||||
|
.album-state-card,
|
||||||
|
.album-card {
|
||||||
|
@include adaptive-family-content;
|
||||||
|
background-color: rgba($paper, 0.82);
|
||||||
|
}
|
||||||
|
.album-state-card {
|
||||||
|
min-height: 340rpx;
|
||||||
|
padding: 78rpx 52rpx 50rpx;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.album-state-card text,
|
||||||
|
.album-card text {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.album-card__actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 12rpx;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-top: 14rpx;
|
||||||
|
}
|
||||||
|
.album-list__error {
|
||||||
|
display: block;
|
||||||
|
color: $brand-red;
|
||||||
|
font-size: clamp(13px, 21rpx, 16px);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
.album-state-card text:first-child,
|
||||||
|
.album-card text:first-child {
|
||||||
|
color: $ink;
|
||||||
|
font-family: STKaiti, KaiTi, serif;
|
||||||
|
font-size: clamp(19px, 35rpx, 24px);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.album-state-card text:nth-child(2),
|
||||||
|
.album-card text:not(:first-child) {
|
||||||
|
margin-top: 12rpx;
|
||||||
|
color: $ink-muted;
|
||||||
|
font-size: clamp(14px, 23rpx, 17px);
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
.album-state-card .app-button {
|
||||||
|
margin-top: 28rpx;
|
||||||
|
}
|
||||||
|
.album-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 20rpx;
|
||||||
|
}
|
||||||
|
.album-card {
|
||||||
|
padding: 28rpx 30rpx;
|
||||||
|
}
|
||||||
|
.album-card__cover {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 240rpx;
|
||||||
|
margin-bottom: 20rpx;
|
||||||
|
border-radius: 10rpx;
|
||||||
|
background: rgba(128, 89, 49, 0.12);
|
||||||
|
}
|
||||||
|
.album-dialog-field {
|
||||||
|
width: 100%;
|
||||||
|
margin: 20rpx 0;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.album-dialog-field > text:first-child,
|
||||||
|
.album-dialog-field--cover > view > text:first-child {
|
||||||
|
display: block;
|
||||||
|
color: $ink;
|
||||||
|
font-size: clamp(14px, 23rpx, 17px);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.required-mark {
|
||||||
|
display: inline-block;
|
||||||
|
margin-right: 4rpx;
|
||||||
|
color: $brand-red;
|
||||||
|
font-size: clamp(16px, 26rpx, 20px);
|
||||||
|
}
|
||||||
|
.album-dialog-field input,
|
||||||
|
.album-dialog-field textarea {
|
||||||
|
@include adaptive-family-field;
|
||||||
|
box-sizing: border-box;
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 10rpx;
|
||||||
|
padding: 16rpx 22rpx;
|
||||||
|
color: $ink;
|
||||||
|
font-size: clamp(15px, 24rpx, 18px);
|
||||||
|
}
|
||||||
|
.album-dialog-field input {
|
||||||
|
min-height: 80rpx;
|
||||||
|
padding-top: 0;
|
||||||
|
padding-bottom: 0;
|
||||||
|
}
|
||||||
|
.album-dialog-field textarea {
|
||||||
|
min-height: 112rpx;
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
.album-dialog-field--cover {
|
||||||
|
display: grid;
|
||||||
|
gap: 12rpx;
|
||||||
|
padding: 18rpx;
|
||||||
|
border: 1rpx solid rgba(128, 89, 49, 0.34);
|
||||||
|
border-radius: 12rpx;
|
||||||
|
background: rgba(255, 252, 245, 0.7);
|
||||||
|
}
|
||||||
|
.album-field-hint {
|
||||||
|
display: block;
|
||||||
|
margin-top: 6rpx;
|
||||||
|
color: $ink-muted;
|
||||||
|
font-size: clamp(13px, 20rpx, 16px);
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
.upload-button {
|
||||||
|
justify-self: start;
|
||||||
|
min-height: 88rpx;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0 20rpx;
|
||||||
|
border: 1rpx solid rgba(159, 23, 15, 0.42);
|
||||||
|
border-radius: 8rpx;
|
||||||
|
background: transparent;
|
||||||
|
color: $brand-red;
|
||||||
|
font-size: clamp(14px, 22rpx, 17px);
|
||||||
|
}
|
||||||
|
.upload-receipt {
|
||||||
|
color: $ink-muted;
|
||||||
|
font-size: clamp(13px, 21rpx, 16px);
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
.album-field-error {
|
||||||
|
display: block;
|
||||||
|
margin-top: 8rpx;
|
||||||
|
color: $brand-red;
|
||||||
|
font-size: clamp(13px, 21rpx, 16px);
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,526 @@
|
|||||||
|
<template>
|
||||||
|
<view class="article-detail-page" :class="`article-state--${articleState}`">
|
||||||
|
<ModulePageBackground module="family" />
|
||||||
|
<view class="article-detail-header"
|
||||||
|
><PageHeader title="谱文详情" custom-back @back="requestBack"
|
||||||
|
/></view>
|
||||||
|
|
||||||
|
<view class="article-detail-content">
|
||||||
|
<AppLoading
|
||||||
|
v-if="articleState === 'loading'"
|
||||||
|
text="正在读取谱文"
|
||||||
|
description="请稍候,正在同步谱文正文。"
|
||||||
|
/>
|
||||||
|
<view v-else-if="articleState === 'ready'" class="article-card">
|
||||||
|
<image
|
||||||
|
v-if="article.coverFile?.accessUrl"
|
||||||
|
class="article-card__cover"
|
||||||
|
:src="article.coverFile.accessUrl"
|
||||||
|
mode="aspectFill"
|
||||||
|
role="button"
|
||||||
|
aria-label="查看谱文封面"
|
||||||
|
@click="previewCover"
|
||||||
|
/>
|
||||||
|
<text v-if="article.category" class="article-card__category">{{
|
||||||
|
article.category
|
||||||
|
}}</text>
|
||||||
|
<text class="article-card__title">{{ article.title }}</text>
|
||||||
|
<text class="article-card__meta"
|
||||||
|
>{{ article.author }} · {{ article.time || "未标注时间" }}</text
|
||||||
|
>
|
||||||
|
<text v-if="article.summary" class="article-card__summary">{{
|
||||||
|
article.summary
|
||||||
|
}}</text>
|
||||||
|
<view class="article-card__divider" />
|
||||||
|
<view v-if="article.contentProtected && !article.contentUnlocked" class="article-lock-card">
|
||||||
|
<text>这篇谱文已设置内容密码</text>
|
||||||
|
<input v-model="protectionPassword" password maxlength="128" placeholder="请输入8至128位内容密码" />
|
||||||
|
<AppButton block :disabled="protectionSubmitting" :label="protectionSubmitting ? '正在验证' : '解锁并查看'" @click="unlockArticle" />
|
||||||
|
<button class="article-lock-card__recovery" @click="passwordRecoveryVisible = true">忘记内容密码?</button>
|
||||||
|
<text v-if="protectionError">{{ protectionError }}</text>
|
||||||
|
</view>
|
||||||
|
<text class="article-card__content">{{
|
||||||
|
article.contentProtected && !article.contentUnlocked ? "" : article.content || "作者暂未填写正文。"
|
||||||
|
}}</text>
|
||||||
|
<FamilyFeedMedia
|
||||||
|
v-if="!article.contentProtected || article.contentUnlocked"
|
||||||
|
:files="article.mediaFiles"
|
||||||
|
image-label="查看谱文正文图片"
|
||||||
|
/>
|
||||||
|
<text class="article-card__views">阅读 {{ article.viewCount }} 次</text>
|
||||||
|
<view
|
||||||
|
v-if="article.canEdit || article.canDelete || article.canManageProtection"
|
||||||
|
class="article-card__actions"
|
||||||
|
>
|
||||||
|
<AppButton
|
||||||
|
v-if="article.canEdit && article.content"
|
||||||
|
compact
|
||||||
|
type="secondary"
|
||||||
|
label="编辑谱文"
|
||||||
|
@click="editArticle"
|
||||||
|
/>
|
||||||
|
<AppButton
|
||||||
|
v-if="article.canDelete"
|
||||||
|
compact
|
||||||
|
type="secondary"
|
||||||
|
label="删除谱文"
|
||||||
|
@click="requestDeleteArticle"
|
||||||
|
/>
|
||||||
|
<AppButton
|
||||||
|
v-if="article.canManageProtection"
|
||||||
|
compact
|
||||||
|
type="secondary"
|
||||||
|
:label="article.contentProtected ? '修改内容密码' : '设置内容密码'"
|
||||||
|
@click="openProtectionDialog('set')"
|
||||||
|
/>
|
||||||
|
<AppButton
|
||||||
|
v-if="article.canManageProtection && article.contentProtected"
|
||||||
|
compact
|
||||||
|
type="secondary"
|
||||||
|
label="关闭内容密码"
|
||||||
|
@click="openProtectionDialog('disable')"
|
||||||
|
/>
|
||||||
|
</view>
|
||||||
|
<text v-if="deleteError" class="article-card__error">{{ deleteError }}</text>
|
||||||
|
</view>
|
||||||
|
<view v-else class="article-state-card">
|
||||||
|
<text>{{ stateCopy.title }}</text>
|
||||||
|
<text>{{ stateCopy.copy }}</text>
|
||||||
|
<AppButton block :label="stateCopy.action" @click="handleStateAction" />
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<AppDialog
|
||||||
|
:visible="deleteConfirmationVisible"
|
||||||
|
:close-on-mask="false"
|
||||||
|
eyebrow="删除确认"
|
||||||
|
title="将这篇谱文移至回收站?"
|
||||||
|
message="移入回收站后不再展示,家谱管理员可在保留期内恢复。"
|
||||||
|
confirm-text="移至回收站"
|
||||||
|
cancel-text="保留谱文"
|
||||||
|
show-cancel
|
||||||
|
@confirm="deleteArticle"
|
||||||
|
@cancel="closeDeleteConfirmation"
|
||||||
|
/>
|
||||||
|
<AppDialog
|
||||||
|
:visible="protectionDialogVisible"
|
||||||
|
eyebrow="内容密码"
|
||||||
|
:title="protectionMode === 'disable' ? '关闭内容密码?' : article?.contentProtected ? '修改内容密码' : '设置内容密码'"
|
||||||
|
:message="protectionMode === 'disable' ? '关闭后,有权查看谱文的成员无需密码即可阅读正文。' : '设置8至128位密码,之后阅读正文需要先验证。'"
|
||||||
|
:confirm-text="protectionSubmitting ? '正在保存' : protectionMode === 'disable' ? '确认关闭' : '确认保存'"
|
||||||
|
cancel-text="取消"
|
||||||
|
show-cancel
|
||||||
|
:close-on-mask="false"
|
||||||
|
@confirm="confirmProtectionChange"
|
||||||
|
@cancel="closeProtectionDialog"
|
||||||
|
>
|
||||||
|
<input v-if="protectionMode === 'set'" v-model="protectionPassword" class="protection-dialog-input" password maxlength="128" placeholder="请输入8至128位内容密码" />
|
||||||
|
<text v-if="protectionError" class="article-card__error">{{ protectionError }}</text>
|
||||||
|
</AppDialog>
|
||||||
|
<ContentPasswordRecoveryDialog
|
||||||
|
:visible="passwordRecoveryVisible"
|
||||||
|
:genealogy-id="genealogyId"
|
||||||
|
resource-type="ARTICLE"
|
||||||
|
:resource-id="articleId"
|
||||||
|
@close="passwordRecoveryVisible = false"
|
||||||
|
@complete="completePasswordRecovery"
|
||||||
|
@busy-change="passwordRecoveryBusy = $event"
|
||||||
|
/>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, ref } from "vue";
|
||||||
|
import { onBackPress, onLoad, onShow, onUnload } from "@dcloudio/uni-app";
|
||||||
|
import AppButton from "@/components/AppButton.vue";
|
||||||
|
import AppDialog from "@/components/AppDialog.vue";
|
||||||
|
import AppLoading from "@/components/AppLoading.vue";
|
||||||
|
import ContentPasswordRecoveryDialog from "@/components/ContentPasswordRecoveryDialog.vue";
|
||||||
|
import FamilyFeedMedia from "@/components/family/FeedMedia.vue";
|
||||||
|
import ModulePageBackground from "@/components/ModulePageBackground.vue";
|
||||||
|
import PageHeader from "@/components/PageHeader.vue";
|
||||||
|
import {
|
||||||
|
createRequestController,
|
||||||
|
isRequestCancelled
|
||||||
|
} from "@/services/api/request-controller.js";
|
||||||
|
import { familyArticleApi } from "@/services/api/family-article-service.js";
|
||||||
|
import { getRequestErrorMessage } from "@/services/api/request-error-message.js";
|
||||||
|
import { goBack, handleBackPress, openPage, returnTo } from "@/utils/navigation/gateway.js";
|
||||||
|
|
||||||
|
const genealogyId = ref("");
|
||||||
|
const articleId = ref("");
|
||||||
|
const articleState = ref("loading");
|
||||||
|
const article = ref(null);
|
||||||
|
const deleteConfirmationVisible = ref(false);
|
||||||
|
const deleting = ref(false);
|
||||||
|
const deleteError = ref("");
|
||||||
|
const articleAccessToken = ref("");
|
||||||
|
const protectionPassword = ref("");
|
||||||
|
const protectionError = ref("");
|
||||||
|
const protectionDialogVisible = ref(false);
|
||||||
|
const protectionMode = ref("set");
|
||||||
|
const protectionSubmitting = ref(false);
|
||||||
|
const passwordRecoveryVisible = ref(false);
|
||||||
|
const passwordRecoveryBusy = ref(false);
|
||||||
|
const articleReadController = createRequestController();
|
||||||
|
const articleProtectionController = createRequestController();
|
||||||
|
const articleDeleteController = createRequestController();
|
||||||
|
let pageActive = true;
|
||||||
|
let skipInitialShowRefresh = true;
|
||||||
|
|
||||||
|
const hasValidContext = computed(
|
||||||
|
() =>
|
||||||
|
/^[1-9]\d*$/.test(genealogyId.value) && /^[1-9]\d*$/.test(articleId.value),
|
||||||
|
);
|
||||||
|
const previewCover = () => {
|
||||||
|
const url = article.value?.coverFile?.accessUrl;
|
||||||
|
if (!url || typeof uni?.previewImage !== "function") return;
|
||||||
|
uni.previewImage({ current: url, urls: [url] });
|
||||||
|
};
|
||||||
|
const stateCopy = computed(() => {
|
||||||
|
if (!hasValidContext.value) {
|
||||||
|
return {
|
||||||
|
title: "暂时无法打开谱文",
|
||||||
|
copy: "未找到家谱或谱文信息,请返回后重新进入。",
|
||||||
|
action: "返回上一页",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (articleState.value === "missing") {
|
||||||
|
return {
|
||||||
|
title: "该谱文已不存在",
|
||||||
|
copy: "它可能已被作者删除,或你暂时无法查看。",
|
||||||
|
action: "返回谱文列表",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
title: "谱文暂时无法读取",
|
||||||
|
copy: "请检查网络后重新读取。",
|
||||||
|
action: "重新读取",
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const loadArticle = async (accessToken = articleAccessToken.value) => {
|
||||||
|
if (!hasValidContext.value) {
|
||||||
|
articleState.value = "invalid";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
articleState.value = "loading";
|
||||||
|
try {
|
||||||
|
const current = await familyArticleApi.getArticleDetail(
|
||||||
|
genealogyId.value,
|
||||||
|
articleId.value,
|
||||||
|
accessToken,
|
||||||
|
{ requestController: articleReadController },
|
||||||
|
);
|
||||||
|
if (!pageActive) return;
|
||||||
|
article.value = current;
|
||||||
|
articleState.value = "ready";
|
||||||
|
} catch (error) {
|
||||||
|
if (!pageActive || isRequestCancelled(error)) return;
|
||||||
|
articleState.value =
|
||||||
|
error?.code === "HTTP_ERROR" && error?.httpStatus === 404
|
||||||
|
? "missing"
|
||||||
|
: "error";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
onLoad((query) => {
|
||||||
|
genealogyId.value = String(query?.genealogyId || "");
|
||||||
|
articleId.value = String(query?.articleId || "");
|
||||||
|
void loadArticle();
|
||||||
|
});
|
||||||
|
onShow(() => {
|
||||||
|
if (skipInitialShowRefresh) {
|
||||||
|
skipInitialShowRefresh = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (hasValidContext.value) void loadArticle();
|
||||||
|
});
|
||||||
|
onUnload(() => {
|
||||||
|
pageActive = false;
|
||||||
|
articleReadController.abort();
|
||||||
|
articleProtectionController.abort();
|
||||||
|
articleDeleteController.abort();
|
||||||
|
});
|
||||||
|
|
||||||
|
const backToArticles = () =>
|
||||||
|
hasValidContext.value
|
||||||
|
? returnTo("F04", { genealogyId: genealogyId.value })
|
||||||
|
: goBack();
|
||||||
|
const handleStateAction = () =>
|
||||||
|
articleState.value === "error" ? loadArticle() : backToArticles();
|
||||||
|
const requestDeleteArticle = () => {
|
||||||
|
if (!article.value?.canDelete || deleting.value) return;
|
||||||
|
deleteError.value = "";
|
||||||
|
deleteConfirmationVisible.value = true;
|
||||||
|
};
|
||||||
|
const unlockArticle = async () => {
|
||||||
|
if (protectionSubmitting.value) return;
|
||||||
|
if (protectionPassword.value.length < 8 || protectionPassword.value.length > 128) {
|
||||||
|
protectionError.value = "请输入8至128位内容密码。";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
protectionSubmitting.value = true;
|
||||||
|
protectionError.value = "";
|
||||||
|
try {
|
||||||
|
const grant = await familyArticleApi.unlockArticle(
|
||||||
|
genealogyId.value,
|
||||||
|
articleId.value,
|
||||||
|
protectionPassword.value,
|
||||||
|
{ requestController: articleProtectionController },
|
||||||
|
);
|
||||||
|
articleAccessToken.value = grant.accessToken;
|
||||||
|
protectionPassword.value = "";
|
||||||
|
await loadArticle(grant.accessToken);
|
||||||
|
} catch (error) {
|
||||||
|
if (!isRequestCancelled(error)) protectionError.value = getRequestErrorMessage(error, "密码不正确,请重新输入。");
|
||||||
|
} finally {
|
||||||
|
protectionSubmitting.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const completePasswordRecovery = (newPassword) => {
|
||||||
|
protectionPassword.value = newPassword;
|
||||||
|
protectionError.value = "内容密码已重置,请点击“解锁并查看”确认。";
|
||||||
|
};
|
||||||
|
const openProtectionDialog = (mode) => {
|
||||||
|
if (!article.value?.canManageProtection || protectionSubmitting.value) return;
|
||||||
|
protectionMode.value = mode;
|
||||||
|
protectionPassword.value = "";
|
||||||
|
protectionError.value = "";
|
||||||
|
protectionDialogVisible.value = true;
|
||||||
|
};
|
||||||
|
const closeProtectionDialog = () => {
|
||||||
|
if (protectionSubmitting.value) return;
|
||||||
|
protectionDialogVisible.value = false;
|
||||||
|
protectionPassword.value = "";
|
||||||
|
protectionError.value = "";
|
||||||
|
};
|
||||||
|
const confirmProtectionChange = async () => {
|
||||||
|
if (!article.value?.canManageProtection || protectionSubmitting.value) return;
|
||||||
|
if (protectionMode.value === "set" && (protectionPassword.value.length < 8 || protectionPassword.value.length > 128)) {
|
||||||
|
protectionError.value = "请输入8至128位内容密码。";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
protectionSubmitting.value = true;
|
||||||
|
protectionError.value = "";
|
||||||
|
try {
|
||||||
|
if (protectionMode.value === "disable") {
|
||||||
|
await familyArticleApi.disableArticlePassword(genealogyId.value, articleId.value, {
|
||||||
|
requestController: articleProtectionController,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await familyArticleApi.setArticlePassword(
|
||||||
|
genealogyId.value,
|
||||||
|
articleId.value,
|
||||||
|
protectionPassword.value,
|
||||||
|
{ requestController: articleProtectionController },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!pageActive) return;
|
||||||
|
articleAccessToken.value = "";
|
||||||
|
protectionDialogVisible.value = false;
|
||||||
|
protectionPassword.value = "";
|
||||||
|
await loadArticle("");
|
||||||
|
} catch (error) {
|
||||||
|
if (pageActive && !isRequestCancelled(error))
|
||||||
|
protectionError.value = getRequestErrorMessage(
|
||||||
|
error,
|
||||||
|
"内容密码设置没有保存,请稍后重试。",
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
if (pageActive) protectionSubmitting.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const editArticle = () =>
|
||||||
|
article.value?.canEdit && article.value.content && !deleting.value
|
||||||
|
? openPage(
|
||||||
|
"F06",
|
||||||
|
{
|
||||||
|
genealogyId: genealogyId.value,
|
||||||
|
articleId: articleId.value,
|
||||||
|
mode: "edit",
|
||||||
|
},
|
||||||
|
"F05",
|
||||||
|
)
|
||||||
|
: Promise.resolve(false);
|
||||||
|
const closeDeleteConfirmation = () => {
|
||||||
|
if (!deleting.value) deleteConfirmationVisible.value = false;
|
||||||
|
};
|
||||||
|
const requestBack = () => {
|
||||||
|
if (deleting.value || protectionSubmitting.value || passwordRecoveryBusy.value) return true;
|
||||||
|
if (passwordRecoveryVisible.value) {
|
||||||
|
passwordRecoveryVisible.value = false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (protectionDialogVisible.value) {
|
||||||
|
closeProtectionDialog();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (deleteConfirmationVisible.value) {
|
||||||
|
closeDeleteConfirmation();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return backToArticles();
|
||||||
|
};
|
||||||
|
onBackPress((event) => handleBackPress(event, requestBack));
|
||||||
|
const deleteArticle = async () => {
|
||||||
|
if (!article.value?.canDelete || deleting.value) return;
|
||||||
|
deleting.value = true;
|
||||||
|
deleteError.value = "";
|
||||||
|
let deletionCommitted = false;
|
||||||
|
try {
|
||||||
|
await familyArticleApi.deleteArticle(genealogyId.value, articleId.value, {
|
||||||
|
requestController: articleDeleteController,
|
||||||
|
});
|
||||||
|
deletionCommitted = true;
|
||||||
|
if (!pageActive) return;
|
||||||
|
deleteConfirmationVisible.value = false;
|
||||||
|
article.value = null;
|
||||||
|
articleState.value = "missing";
|
||||||
|
await backToArticles();
|
||||||
|
} catch (error) {
|
||||||
|
if (!pageActive) return;
|
||||||
|
if (deletionCommitted) {
|
||||||
|
deleteConfirmationVisible.value = false;
|
||||||
|
article.value = null;
|
||||||
|
articleState.value = "missing";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isRequestCancelled(error)) return;
|
||||||
|
deleteError.value = getRequestErrorMessage(error, "谱文删除失败,请稍后重试。");
|
||||||
|
deleteConfirmationVisible.value = false;
|
||||||
|
} finally {
|
||||||
|
if (pageActive) deleting.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
@import "../../styles/adaptive-frame-profiles.scss";
|
||||||
|
|
||||||
|
.article-detail-page {
|
||||||
|
display: flex;
|
||||||
|
min-height: 100vh;
|
||||||
|
flex-direction: column;
|
||||||
|
background: $paper;
|
||||||
|
}
|
||||||
|
.article-detail-header,
|
||||||
|
.article-detail-content {
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
.article-detail-content {
|
||||||
|
flex: 1;
|
||||||
|
padding: 18rpx 24rpx calc(72rpx + env(safe-area-inset-bottom));
|
||||||
|
}
|
||||||
|
.article-card,
|
||||||
|
.article-state-card {
|
||||||
|
@include adaptive-family-content;
|
||||||
|
box-sizing: border-box;
|
||||||
|
background-color: rgba($paper, 0.82);
|
||||||
|
}
|
||||||
|
.article-card {
|
||||||
|
margin-top: 18rpx;
|
||||||
|
padding: 34rpx 30rpx;
|
||||||
|
}
|
||||||
|
.article-card__category,
|
||||||
|
.article-card__title,
|
||||||
|
.article-card__meta,
|
||||||
|
.article-card__summary,
|
||||||
|
.article-card__content,
|
||||||
|
.article-card__views {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.article-card__category {
|
||||||
|
color: $brand-red;
|
||||||
|
font-size: clamp(14px, 23rpx, 17px);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.article-card__title {
|
||||||
|
margin-top: 14rpx;
|
||||||
|
color: $ink;
|
||||||
|
font-family: STKaiti, KaiTi, serif;
|
||||||
|
font-size: clamp(20px, 40rpx, 26px);
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.32;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
.article-card__meta {
|
||||||
|
margin-top: 16rpx;
|
||||||
|
color: $ink-muted;
|
||||||
|
font-size: clamp(14px, 22rpx, 17px);
|
||||||
|
}
|
||||||
|
.article-card__summary {
|
||||||
|
margin-top: 22rpx;
|
||||||
|
color: $ink-muted;
|
||||||
|
font-size: clamp(15px, 25rpx, 18px);
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
.article-card__divider {
|
||||||
|
height: 1rpx;
|
||||||
|
margin: 26rpx 0;
|
||||||
|
background: rgba(128, 89, 49, 0.22);
|
||||||
|
}
|
||||||
|
.article-card__content {
|
||||||
|
color: $ink;
|
||||||
|
font-size: clamp(16px, 28rpx, 20px);
|
||||||
|
line-height: 1.85;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
.article-card__views {
|
||||||
|
margin-top: 30rpx;
|
||||||
|
color: $ink-muted;
|
||||||
|
font-size: clamp(13px, 21rpx, 16px);
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
.article-card__cover {
|
||||||
|
width: 100%;
|
||||||
|
height: 360rpx;
|
||||||
|
margin-bottom: 22rpx;
|
||||||
|
border-radius: 10rpx;
|
||||||
|
background: rgba(128, 89, 49, 0.12);
|
||||||
|
}
|
||||||
|
.article-card__actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10rpx;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-top: 18rpx;
|
||||||
|
}
|
||||||
|
.article-lock-card { margin: 16rpx 0; padding: 24rpx; border: 1rpx solid rgba(159, 23, 15, 0.3); border-radius: 10rpx; background: rgba(159, 23, 15, 0.05); }
|
||||||
|
.article-lock-card > text { display: block; color: $ink; font-size: clamp(14px, 22rpx, 17px); }
|
||||||
|
.article-lock-card__recovery { min-height: var(--app-touch-min); margin: 4rpx auto 0; padding: 0 16rpx; border: 0; background: transparent; color: #9e251b; font-size: clamp(13px, 22rpx, 16px); }
|
||||||
|
.article-lock-card__recovery::after { border: 0; }
|
||||||
|
.article-lock-card input,
|
||||||
|
.protection-dialog-input { box-sizing: border-box; width: 100%; min-height: 80rpx; margin: 16rpx 0; padding: 14rpx 18rpx; border: 1rpx solid rgba(128, 89, 49, 0.32); border-radius: 8rpx; background: #fffdf8; color: $ink; font-size: clamp(14px, 22rpx, 17px); }
|
||||||
|
.article-card__error {
|
||||||
|
margin-top: 12rpx;
|
||||||
|
color: $brand-red;
|
||||||
|
font-size: clamp(13px, 21rpx, 16px);
|
||||||
|
line-height: 1.5;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
.article-state-card {
|
||||||
|
min-height: 350rpx;
|
||||||
|
margin-top: 36rpx;
|
||||||
|
padding: 78rpx 52rpx 50rpx;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.article-state-card > text {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.article-state-card > text:first-child {
|
||||||
|
color: $ink;
|
||||||
|
font-family: STKaiti, KaiTi, serif;
|
||||||
|
font-size: clamp(19px, 35rpx, 24px);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.article-state-card > text:nth-child(2) {
|
||||||
|
margin-top: 18rpx;
|
||||||
|
color: $ink-muted;
|
||||||
|
font-size: clamp(15px, 24rpx, 18px);
|
||||||
|
line-height: 1.65;
|
||||||
|
}
|
||||||
|
.article-state-card .app-button {
|
||||||
|
margin-top: 28rpx;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,798 @@
|
|||||||
|
<template>
|
||||||
|
<view
|
||||||
|
class="article-editor-page"
|
||||||
|
:class="`article-editor-state--${editorState}`"
|
||||||
|
>
|
||||||
|
<ModulePageBackground module="family" />
|
||||||
|
<view class="article-editor-page__header"
|
||||||
|
><PageHeader :title="isEdit ? '编辑谱文' : '新建谱文'" custom-back @back="requestBack"
|
||||||
|
/></view>
|
||||||
|
<view class="article-editor-content">
|
||||||
|
<AppLoading v-if="editorState === 'loading'" text="正在读取谱文" />
|
||||||
|
<view v-else-if="editorState === 'form'" class="editor-panel">
|
||||||
|
<view class="editor-panel__body">
|
||||||
|
<text class="editor-eyebrow">{{ isEdit ? '编辑谱文' : '新建谱文' }}</text>
|
||||||
|
<text class="editor-title">{{ isEdit ? '更新这篇传承故事' : '把值得传承的故事写下来' }}</text>
|
||||||
|
<text class="editor-intro"
|
||||||
|
>{{ isEdit ? '未重新选择封面时,原封面会保留。' : '除标题和正文外,其他内容可按需填写。' }}</text
|
||||||
|
>
|
||||||
|
|
||||||
|
<view class="editor-field">
|
||||||
|
<text class="editor-field__label">文章分类</text>
|
||||||
|
<picker
|
||||||
|
:range="categoryOptionLabels"
|
||||||
|
:value="categoryOptionIndex"
|
||||||
|
:disabled="categoryOptionsState !== 'ready' || !categoryOptions.length"
|
||||||
|
@change="selectCategory"
|
||||||
|
>
|
||||||
|
<view class="editor-control editor-control--picker" :class="{ 'editor-control--unavailable': categoryOptionsState !== 'ready' }">
|
||||||
|
<text>{{ categoryOptionLabel }}</text>
|
||||||
|
</view>
|
||||||
|
</picker>
|
||||||
|
<text v-if="categoryOptionsState === 'loading'" class="editor-field__hint">正在获取文章分类</text>
|
||||||
|
<text v-else-if="categoryOptionsState === 'error'" class="editor-field__hint">暂时无法获取文章分类,可不选分类继续填写。</text>
|
||||||
|
</view>
|
||||||
|
<view class="editor-field">
|
||||||
|
<text class="editor-field__label"
|
||||||
|
><text class="required-mark">*</text>文章标题</text
|
||||||
|
>
|
||||||
|
<view class="editor-control"
|
||||||
|
><input
|
||||||
|
v-model="form.articleTitle"
|
||||||
|
placeholder="请输入文章标题"
|
||||||
|
placeholder-class="editor-placeholder"
|
||||||
|
@input="submitError = ''"
|
||||||
|
/></view>
|
||||||
|
</view>
|
||||||
|
<view class="editor-field">
|
||||||
|
<text class="editor-field__label">文章摘要</text>
|
||||||
|
<view class="editor-control editor-control--textarea">
|
||||||
|
<textarea
|
||||||
|
v-model="form.articleSummary"
|
||||||
|
auto-height
|
||||||
|
placeholder="概括文章内容"
|
||||||
|
placeholder-class="editor-placeholder"
|
||||||
|
@input="submitError = ''"
|
||||||
|
/>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view class="editor-field editor-field--cover">
|
||||||
|
<view>
|
||||||
|
<text class="editor-field__label">封面图片</text>
|
||||||
|
<text class="editor-field__hint"
|
||||||
|
>图片上传成功后,会作为谱文封面保存。</text
|
||||||
|
>
|
||||||
|
</view>
|
||||||
|
<button
|
||||||
|
class="upload-button"
|
||||||
|
:disabled="uploading || isSubmitting"
|
||||||
|
@click="uploadCover"
|
||||||
|
>
|
||||||
|
{{ uploading ? "上传中…" : "选择图片" }}
|
||||||
|
</button>
|
||||||
|
<text v-if="coverFileName" class="upload-receipt"
|
||||||
|
>已上传:{{ coverFileName }}</text
|
||||||
|
>
|
||||||
|
<button v-if="coverOssId" class="remove-cover-button" :disabled="uploading || isSubmitting" @click="clearCover">移除封面</button>
|
||||||
|
<text v-if="uploadError" class="editor-save-error">{{
|
||||||
|
uploadError
|
||||||
|
}}</text>
|
||||||
|
</view>
|
||||||
|
<view class="editor-field">
|
||||||
|
<text class="editor-field__label"
|
||||||
|
><text class="required-mark">*</text>正文内容</text
|
||||||
|
>
|
||||||
|
<view
|
||||||
|
class="editor-control editor-control--textarea editor-control--article"
|
||||||
|
>
|
||||||
|
<textarea
|
||||||
|
v-model="form.articleContent"
|
||||||
|
auto-height
|
||||||
|
placeholder="记录家训、往事或想留给后人的话"
|
||||||
|
placeholder-class="editor-placeholder"
|
||||||
|
@input="submitError = ''"
|
||||||
|
/>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view class="editor-field editor-field--media">
|
||||||
|
<view>
|
||||||
|
<text class="editor-field__label">正文图片</text>
|
||||||
|
<text class="editor-field__hint">图片按添加顺序显示,可单独移除。</text>
|
||||||
|
</view>
|
||||||
|
<button
|
||||||
|
class="upload-button"
|
||||||
|
:disabled="uploading || isSubmitting"
|
||||||
|
@click="uploadArticleMedia"
|
||||||
|
>
|
||||||
|
{{ uploading ? "上传中…" : "添加正文图片" }}
|
||||||
|
</button>
|
||||||
|
<view v-if="mediaReceipts.length" class="body-media-list">
|
||||||
|
<view
|
||||||
|
v-for="(receipt, index) in mediaReceipts"
|
||||||
|
:key="`${receipt.ossId}-${index}`"
|
||||||
|
class="body-media-item"
|
||||||
|
>
|
||||||
|
<text>{{ index + 1 }}. {{ receipt.fileName || "正文图片" }}</text>
|
||||||
|
<button
|
||||||
|
class="remove-media-button"
|
||||||
|
:disabled="uploading || isSubmitting"
|
||||||
|
:aria-label="`移除第${index + 1}张正文图片`"
|
||||||
|
@click="removeArticleMedia(index)"
|
||||||
|
>移除</button>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<text v-if="mediaUploadError" class="editor-save-error">{{ mediaUploadError }}</text>
|
||||||
|
</view>
|
||||||
|
<view class="editor-field">
|
||||||
|
<text class="editor-field__label">作者名称</text>
|
||||||
|
<view class="editor-control"
|
||||||
|
><input
|
||||||
|
v-model="form.authorName"
|
||||||
|
placeholder="请输入作者名称"
|
||||||
|
placeholder-class="editor-placeholder"
|
||||||
|
@input="submitError = ''"
|
||||||
|
/></view>
|
||||||
|
</view>
|
||||||
|
<view v-if="!isEdit" class="editor-field">
|
||||||
|
<text class="editor-field__label">内容密码(选填)</text>
|
||||||
|
<text class="editor-field__hint">填写后会先创建谱文,再立即启用密码保护;密码设置失败时会明确提示。</text>
|
||||||
|
<view class="editor-control">
|
||||||
|
<input
|
||||||
|
v-model="contentPassword"
|
||||||
|
password
|
||||||
|
maxlength="128"
|
||||||
|
placeholder="请输入8至128位内容密码"
|
||||||
|
placeholder-class="editor-placeholder"
|
||||||
|
@input="submitError = ''"
|
||||||
|
/>
|
||||||
|
</view>
|
||||||
|
<view class="editor-control editor-control--password-confirm">
|
||||||
|
<input
|
||||||
|
v-model="contentPasswordConfirm"
|
||||||
|
password
|
||||||
|
maxlength="128"
|
||||||
|
placeholder="请再次输入内容密码"
|
||||||
|
placeholder-class="editor-placeholder"
|
||||||
|
@input="submitError = ''"
|
||||||
|
/>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<text v-if="submitError" class="editor-save-error">{{
|
||||||
|
submitError
|
||||||
|
}}</text>
|
||||||
|
<AppButton
|
||||||
|
block
|
||||||
|
:label="isSubmitting ? '正在提交' : isEdit ? '保存修改' : '提交谱文'"
|
||||||
|
:disabled="isSubmitting || uploading"
|
||||||
|
@click="saveArticle"
|
||||||
|
/>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view v-else class="editor-result-card">
|
||||||
|
<view class="editor-result-card__body">
|
||||||
|
<text class="editor-eyebrow">{{ resultCopy.eyebrow }}</text>
|
||||||
|
<text class="editor-result-card__title">{{ resultCopy.title }}</text>
|
||||||
|
<text class="editor-result-card__copy">{{ resultCopy.copy }}</text>
|
||||||
|
<AppButton
|
||||||
|
block
|
||||||
|
:label="resultCopy.action"
|
||||||
|
@click="handleResultAction"
|
||||||
|
/>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<AppDialog
|
||||||
|
:visible="discardVisible"
|
||||||
|
title="放弃谱文草稿?"
|
||||||
|
message="谱文还没有保存,确认返回后将不会保留。"
|
||||||
|
confirm-text="放弃并返回"
|
||||||
|
cancel-text="继续编辑"
|
||||||
|
show-cancel
|
||||||
|
:close-on-mask="false"
|
||||||
|
@confirm="confirmDiscard"
|
||||||
|
@cancel="cancelDiscard"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, reactive, ref } from "vue";
|
||||||
|
import { onBackPress, onLoad, onUnload } from "@dcloudio/uni-app";
|
||||||
|
import AppButton from "@/components/AppButton.vue";
|
||||||
|
import AppDialog from "@/components/AppDialog.vue";
|
||||||
|
import AppLoading from "@/components/AppLoading.vue";
|
||||||
|
import ModulePageBackground from "@/components/ModulePageBackground.vue";
|
||||||
|
import PageHeader from "@/components/PageHeader.vue";
|
||||||
|
import {
|
||||||
|
createRequestController,
|
||||||
|
isRequestCancelled
|
||||||
|
} from "@/services/api/request-controller.js";
|
||||||
|
import { familyArticleApi } from "@/services/api/family-article-service.js";
|
||||||
|
import { getRequestErrorMessage } from "@/services/api/request-error-message.js";
|
||||||
|
import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
|
||||||
|
import { createNonIdempotentWriteGuard } from "@/utils/non-idempotent-write-guard.js";
|
||||||
|
import {
|
||||||
|
isImagePickCancelled,
|
||||||
|
pickAndUploadImage,
|
||||||
|
} from "@/utils/media-upload.js";
|
||||||
|
import {
|
||||||
|
goBack,
|
||||||
|
handleBackPress,
|
||||||
|
returnTo,
|
||||||
|
runBackGuard,
|
||||||
|
} from "@/utils/navigation/gateway.js";
|
||||||
|
|
||||||
|
const editorState = ref("form");
|
||||||
|
const genealogyId = ref("");
|
||||||
|
const articleId = ref("");
|
||||||
|
const mode = ref("");
|
||||||
|
const isSubmitting = ref(false);
|
||||||
|
const uploading = ref(false);
|
||||||
|
const discardVisible = ref(false);
|
||||||
|
const submitError = ref("");
|
||||||
|
const uploadError = ref("");
|
||||||
|
const mediaUploadError = ref("");
|
||||||
|
const coverOssId = ref(null);
|
||||||
|
const coverFileName = ref("");
|
||||||
|
const mediaReceipts = ref([]);
|
||||||
|
const contentPassword = ref("");
|
||||||
|
const contentPasswordConfirm = ref("");
|
||||||
|
const passwordSetupError = ref("");
|
||||||
|
const passwordProtectionEnabled = ref(false);
|
||||||
|
const categoryOptionsState = ref("loading");
|
||||||
|
const categoryOptions = ref([]);
|
||||||
|
const preservedUpdateFields = ref({ sortOrder: null, status: "" });
|
||||||
|
const formBaseline = ref("");
|
||||||
|
const form = reactive({
|
||||||
|
categoryId: "",
|
||||||
|
articleTitle: "",
|
||||||
|
articleSummary: "",
|
||||||
|
articleContent: "",
|
||||||
|
authorName: "",
|
||||||
|
});
|
||||||
|
const articleDetailController = createRequestController();
|
||||||
|
const articleCategoryController = createRequestController();
|
||||||
|
const articleCoverUploadController = createRequestController();
|
||||||
|
const articleMediaUploadController = createRequestController();
|
||||||
|
const articleSaveController = createRequestController();
|
||||||
|
const articlePasswordController = createRequestController();
|
||||||
|
const articleCreateGuard = createNonIdempotentWriteGuard();
|
||||||
|
let pageActive = true;
|
||||||
|
const categoryOptionLabels = computed(() => ["不设置分类", ...categoryOptions.value.map((item) => item.name)]);
|
||||||
|
const categoryOptionIndex = computed(() => {
|
||||||
|
const index = categoryOptions.value.findIndex((item) => item.id === form.categoryId);
|
||||||
|
return index < 0 ? 0 : index + 1;
|
||||||
|
});
|
||||||
|
const categoryOptionLabel = computed(() => categoryOptionLabels.value[categoryOptionIndex.value] || "不设置分类");
|
||||||
|
const isEdit = computed(() => mode.value === "edit");
|
||||||
|
const mediaOssIds = computed(() => mediaReceipts.value.map((item) => item.ossId).join(","));
|
||||||
|
const formSnapshot = computed(() =>
|
||||||
|
JSON.stringify({ ...form, coverOssId: coverOssId.value || "", mediaOssIds: mediaOssIds.value }),
|
||||||
|
);
|
||||||
|
const isDirty = computed(() =>
|
||||||
|
isEdit.value
|
||||||
|
? Boolean(formBaseline.value) && formSnapshot.value !== formBaseline.value
|
||||||
|
: Boolean(
|
||||||
|
Object.values(form).some((value) => value.trim()) ||
|
||||||
|
coverOssId.value ||
|
||||||
|
mediaReceipts.value.length ||
|
||||||
|
contentPassword.value ||
|
||||||
|
contentPasswordConfirm.value
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const hasValidContext = computed(
|
||||||
|
() =>
|
||||||
|
/^[1-9]\d*$/.test(genealogyId.value) &&
|
||||||
|
(mode.value === "create" || (mode.value === "edit" && /^[1-9]\d*$/.test(articleId.value))),
|
||||||
|
);
|
||||||
|
const resultCopy = computed(() =>
|
||||||
|
editorState.value === "success"
|
||||||
|
? passwordSetupError.value
|
||||||
|
? {
|
||||||
|
eyebrow: "谱文已创建",
|
||||||
|
title: "密码保护尚未确认",
|
||||||
|
copy: passwordSetupError.value,
|
||||||
|
action: "打开谱文详情",
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
eyebrow: "保存成功",
|
||||||
|
title: isEdit.value ? "谱文已更新" : "谱文已提交",
|
||||||
|
copy: !isEdit.value && passwordProtectionEnabled.value
|
||||||
|
? "谱文已保存,并已启用内容密码。"
|
||||||
|
: "已保存,返回后会显示最新内容。",
|
||||||
|
action: isEdit.value ? "返回谱文详情" : "返回谱文列表",
|
||||||
|
}
|
||||||
|
: editorState.value === "error"
|
||||||
|
? {
|
||||||
|
eyebrow: "谱文暂不可编辑",
|
||||||
|
title: "这篇谱文暂时无法编辑",
|
||||||
|
copy: submitError.value || "请返回后重新查看。",
|
||||||
|
action: "返回谱文详情",
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
eyebrow: "暂时无法打开谱文",
|
||||||
|
title: "无法编辑谱文",
|
||||||
|
copy: "未找到家谱或谱文信息,请返回后重新进入。",
|
||||||
|
action: "返回上一页",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const discardConfirmation = createDiscardConfirmation((visible) => {
|
||||||
|
discardVisible.value = visible;
|
||||||
|
});
|
||||||
|
const requestDiscardConfirmation = discardConfirmation.request;
|
||||||
|
const confirmDiscard = discardConfirmation.confirm;
|
||||||
|
const cancelDiscard = discardConfirmation.cancel;
|
||||||
|
|
||||||
|
onLoad((query) => {
|
||||||
|
genealogyId.value = String(query?.genealogyId || "");
|
||||||
|
articleId.value = String(query?.articleId || "");
|
||||||
|
mode.value = String(query?.mode || "");
|
||||||
|
if (!hasValidContext.value) {
|
||||||
|
editorState.value = "invalid";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void initializeEditor();
|
||||||
|
});
|
||||||
|
|
||||||
|
const initializeEditor = async () => {
|
||||||
|
editorState.value = "loading";
|
||||||
|
try {
|
||||||
|
await loadArticleCategories();
|
||||||
|
if (isEdit.value) await loadArticleForEdit();
|
||||||
|
if (!pageActive) return;
|
||||||
|
if (editorState.value === "loading") editorState.value = "form";
|
||||||
|
} catch (error) {
|
||||||
|
if (!pageActive || isRequestCancelled(error)) return;
|
||||||
|
submitError.value = getRequestErrorMessage(error, "谱文详情暂时无法读取,请稍后重试。");
|
||||||
|
editorState.value = "error";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const loadArticleCategories = async () => {
|
||||||
|
categoryOptionsState.value = "loading";
|
||||||
|
try {
|
||||||
|
const categories = await familyArticleApi.getArticleCategories(genealogyId.value, {
|
||||||
|
requestController: articleCategoryController,
|
||||||
|
});
|
||||||
|
if (!pageActive) return;
|
||||||
|
categoryOptions.value = categories.filter((item) => item.enabled);
|
||||||
|
categoryOptionsState.value = "ready";
|
||||||
|
} catch (error) {
|
||||||
|
if (!pageActive || isRequestCancelled(error)) return;
|
||||||
|
categoryOptions.value = [];
|
||||||
|
categoryOptionsState.value = "error";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const loadArticleForEdit = async () => {
|
||||||
|
const article = await familyArticleApi.getArticleDetail(genealogyId.value, articleId.value, "", {
|
||||||
|
requestController: articleDetailController,
|
||||||
|
});
|
||||||
|
if (!pageActive) return;
|
||||||
|
if (!article.canEdit) {
|
||||||
|
throw new Error("你暂时不能编辑这篇谱文。");
|
||||||
|
}
|
||||||
|
if (!article.content || !["0", "1"].includes(article.status) || !Number.isSafeInteger(article.sortOrder)) {
|
||||||
|
throw new Error("这篇谱文的信息不完整,暂未保存修改,以免覆盖原内容。");
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
article.categoryId &&
|
||||||
|
!categoryOptions.value.some((item) => item.id === article.categoryId)
|
||||||
|
) {
|
||||||
|
throw new Error("这篇谱文暂时无法编辑,请稍后再试。");
|
||||||
|
}
|
||||||
|
Object.assign(form, {
|
||||||
|
categoryId: article.categoryId || "",
|
||||||
|
articleTitle: article.title,
|
||||||
|
articleSummary: article.summary,
|
||||||
|
articleContent: article.content,
|
||||||
|
authorName: article.authorName,
|
||||||
|
});
|
||||||
|
coverOssId.value = article.coverFile?.ossId || null;
|
||||||
|
coverFileName.value = article.coverFile
|
||||||
|
? article.coverFile.fileName || "当前封面图片"
|
||||||
|
: "";
|
||||||
|
mediaReceipts.value = article.mediaFiles.map((file) => ({
|
||||||
|
ossId: file.ossId,
|
||||||
|
fileName: file.fileName || "正文图片",
|
||||||
|
}));
|
||||||
|
preservedUpdateFields.value = {
|
||||||
|
sortOrder: article.sortOrder,
|
||||||
|
status: article.status,
|
||||||
|
};
|
||||||
|
formBaseline.value = formSnapshot.value;
|
||||||
|
};
|
||||||
|
const selectCategory = (event) => {
|
||||||
|
const index = Number(event.detail.value);
|
||||||
|
form.categoryId = index > 0 ? categoryOptions.value[index - 1]?.id || "" : "";
|
||||||
|
submitError.value = "";
|
||||||
|
};
|
||||||
|
|
||||||
|
const uploadCover = async () => {
|
||||||
|
if (uploading.value || isSubmitting.value) return;
|
||||||
|
uploading.value = true;
|
||||||
|
uploadError.value = "";
|
||||||
|
try {
|
||||||
|
const receipt = await pickAndUploadImage({
|
||||||
|
requestController: articleCoverUploadController,
|
||||||
|
});
|
||||||
|
if (!pageActive) return;
|
||||||
|
coverOssId.value = receipt.ossId;
|
||||||
|
coverFileName.value = receipt.fileName || "封面图片";
|
||||||
|
} catch (error) {
|
||||||
|
if (pageActive && !isImagePickCancelled(error) && !isRequestCancelled(error)) {
|
||||||
|
uploadError.value = getRequestErrorMessage(error, "封面图片上传失败,请稍后重试。");
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (pageActive) uploading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearCover = () => {
|
||||||
|
if (uploading.value || isSubmitting.value) return;
|
||||||
|
coverOssId.value = null;
|
||||||
|
coverFileName.value = "";
|
||||||
|
uploadError.value = "";
|
||||||
|
};
|
||||||
|
|
||||||
|
const uploadArticleMedia = async () => {
|
||||||
|
if (uploading.value || isSubmitting.value) return;
|
||||||
|
uploading.value = true;
|
||||||
|
mediaUploadError.value = "";
|
||||||
|
try {
|
||||||
|
const receipt = await pickAndUploadImage({
|
||||||
|
requestController: articleMediaUploadController,
|
||||||
|
});
|
||||||
|
if (!pageActive) return;
|
||||||
|
if (mediaReceipts.value.some((item) => item.ossId === receipt.ossId)) {
|
||||||
|
mediaUploadError.value = "这张图片已经添加。";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
mediaReceipts.value = [...mediaReceipts.value, receipt];
|
||||||
|
} catch (error) {
|
||||||
|
if (pageActive && !isImagePickCancelled(error) && !isRequestCancelled(error)) {
|
||||||
|
mediaUploadError.value = getRequestErrorMessage(error, "正文图片上传失败,请稍后重试。");
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (pageActive) uploading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeArticleMedia = (index) => {
|
||||||
|
if (uploading.value || isSubmitting.value || !Number.isInteger(index)) return;
|
||||||
|
mediaReceipts.value = mediaReceipts.value.filter((_, mediaIndex) => mediaIndex !== index);
|
||||||
|
mediaUploadError.value = "";
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveArticle = async () => {
|
||||||
|
if (isSubmitting.value || uploading.value || !hasValidContext.value) return;
|
||||||
|
if (!form.articleTitle.trim() || !form.articleContent.trim()) {
|
||||||
|
submitError.value = !form.articleTitle.trim()
|
||||||
|
? "请填写文章标题"
|
||||||
|
: "请填写正文内容";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const payload = {
|
||||||
|
...form,
|
||||||
|
coverOssId: coverOssId.value,
|
||||||
|
mediaOssIds: mediaOssIds.value,
|
||||||
|
...(isEdit.value ? preservedUpdateFields.value : {}),
|
||||||
|
};
|
||||||
|
const createAttempt = isEdit.value ? null : articleCreateGuard.begin(payload);
|
||||||
|
if (!isEdit.value && createAttempt === null) {
|
||||||
|
submitError.value =
|
||||||
|
"上次提交结果暂时无法确认,请先返回谱文列表检查,避免重复创建。";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (contentPassword.value || contentPasswordConfirm.value) {
|
||||||
|
if (contentPassword.value.length < 8 || contentPassword.value.length > 128) {
|
||||||
|
submitError.value = "内容密码必须为8至128位。";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (contentPassword.value !== contentPasswordConfirm.value) {
|
||||||
|
submitError.value = "两次输入的内容密码不一致。";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
isSubmitting.value = true;
|
||||||
|
submitError.value = "";
|
||||||
|
passwordSetupError.value = "";
|
||||||
|
try {
|
||||||
|
if (isEdit.value) {
|
||||||
|
await familyArticleApi.updateArticle(genealogyId.value, articleId.value, payload, {
|
||||||
|
requestController: articleSaveController,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const createdArticle = await familyArticleApi.createArticle(genealogyId.value, payload, {
|
||||||
|
requestController: articleSaveController,
|
||||||
|
});
|
||||||
|
if (!pageActive) return;
|
||||||
|
articleId.value = createdArticle.id;
|
||||||
|
if (contentPassword.value) {
|
||||||
|
try {
|
||||||
|
await familyArticleApi.setArticlePassword(
|
||||||
|
genealogyId.value,
|
||||||
|
createdArticle.id,
|
||||||
|
contentPassword.value,
|
||||||
|
{ requestController: articlePasswordController },
|
||||||
|
);
|
||||||
|
passwordProtectionEnabled.value = true;
|
||||||
|
} catch (passwordError) {
|
||||||
|
if (!pageActive || isRequestCancelled(passwordError)) return;
|
||||||
|
const failureCopy = getRequestErrorMessage(passwordError, "内容密码设置失败");
|
||||||
|
passwordSetupError.value = `谱文已经创建,但${failureCopy}。请进入详情重新设置;当前内容可能尚未受到密码保护。`;
|
||||||
|
}
|
||||||
|
contentPassword.value = "";
|
||||||
|
contentPasswordConfirm.value = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!pageActive) return;
|
||||||
|
editorState.value = "success";
|
||||||
|
} catch (error) {
|
||||||
|
if (!pageActive) return;
|
||||||
|
if (!isEdit.value && articleCreateGuard.recordFailure(createAttempt, error)) {
|
||||||
|
submitError.value =
|
||||||
|
"提交结果暂时无法确认,请先返回谱文列表检查,避免重复创建。";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!isRequestCancelled(error))
|
||||||
|
submitError.value = getRequestErrorMessage(error, "谱文提交失败,请稍后重试。");
|
||||||
|
} finally {
|
||||||
|
if (pageActive) isSubmitting.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const requestBack = () =>
|
||||||
|
editorState.value !== "form"
|
||||||
|
? goBack()
|
||||||
|
: runBackGuard({
|
||||||
|
transientOpen: discardVisible.value,
|
||||||
|
dirty: isDirty.value,
|
||||||
|
submitting: isSubmitting.value || uploading.value,
|
||||||
|
"close-transient": cancelDiscard,
|
||||||
|
"block-submitting": () => true,
|
||||||
|
"confirm-discard": requestDiscardConfirmation,
|
||||||
|
});
|
||||||
|
const handleResultAction = () =>
|
||||||
|
editorState.value === "success"
|
||||||
|
? isEdit.value || passwordSetupError.value
|
||||||
|
? returnTo("F05", { genealogyId: genealogyId.value, articleId: articleId.value })
|
||||||
|
: returnTo("F04", { genealogyId: genealogyId.value })
|
||||||
|
: isEdit.value
|
||||||
|
? returnTo("F05", { genealogyId: genealogyId.value, articleId: articleId.value })
|
||||||
|
: goBack();
|
||||||
|
onBackPress((event) => handleBackPress(event, requestBack));
|
||||||
|
onUnload(() => {
|
||||||
|
pageActive = false;
|
||||||
|
articleDetailController.abort();
|
||||||
|
articleCategoryController.abort();
|
||||||
|
articleCoverUploadController.abort();
|
||||||
|
articleMediaUploadController.abort();
|
||||||
|
articleSaveController.abort();
|
||||||
|
articlePasswordController.abort();
|
||||||
|
discardConfirmation.dispose();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
@import "../../styles/adaptive-frame-profiles.scss";
|
||||||
|
.article-editor-page {
|
||||||
|
display: flex;
|
||||||
|
min-height: 100vh;
|
||||||
|
flex-direction: column;
|
||||||
|
background: $paper;
|
||||||
|
}
|
||||||
|
.article-editor-page__header,
|
||||||
|
.article-editor-content {
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
.article-editor-content {
|
||||||
|
padding: 18rpx 20rpx calc(38rpx + env(safe-area-inset-bottom));
|
||||||
|
}
|
||||||
|
.editor-panel,
|
||||||
|
.editor-result-card {
|
||||||
|
@include adaptive-family-panel;
|
||||||
|
width: 100%;
|
||||||
|
background-color: rgba($paper, 0.82);
|
||||||
|
}
|
||||||
|
.editor-panel__body {
|
||||||
|
padding: 38rpx 34rpx 42rpx;
|
||||||
|
}
|
||||||
|
.editor-eyebrow {
|
||||||
|
display: block;
|
||||||
|
color: $brand-red;
|
||||||
|
font-size: clamp(14px, 23rpx, 17px);
|
||||||
|
letter-spacing: 3rpx;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.editor-title {
|
||||||
|
display: block;
|
||||||
|
margin-top: 10rpx;
|
||||||
|
color: $ink;
|
||||||
|
font-family: STKaiti, KaiTi, serif;
|
||||||
|
font-size: clamp(19px, 34rpx, 24px);
|
||||||
|
font-weight: 700;
|
||||||
|
text-align: center;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
.editor-intro {
|
||||||
|
display: block;
|
||||||
|
margin: 10rpx 8rpx 22rpx;
|
||||||
|
color: $ink-muted;
|
||||||
|
font-size: clamp(14px, 23rpx, 17px);
|
||||||
|
line-height: 1.55;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.editor-field {
|
||||||
|
margin-top: 18rpx;
|
||||||
|
}
|
||||||
|
.editor-field__label {
|
||||||
|
display: block;
|
||||||
|
margin: 0 8rpx 8rpx;
|
||||||
|
color: $ink;
|
||||||
|
font-size: clamp(15px, 25rpx, 18px);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.required-mark {
|
||||||
|
display: inline-block;
|
||||||
|
margin-right: 4rpx;
|
||||||
|
color: $brand-red;
|
||||||
|
font-size: clamp(16px, 26rpx, 20px);
|
||||||
|
line-height: 1;
|
||||||
|
transform: translateY(-3rpx);
|
||||||
|
}
|
||||||
|
.editor-control {
|
||||||
|
@include adaptive-family-field;
|
||||||
|
display: flex;
|
||||||
|
min-height: 82rpx;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.editor-control input,
|
||||||
|
.editor-control textarea {
|
||||||
|
box-sizing: border-box;
|
||||||
|
width: 100%;
|
||||||
|
color: $ink;
|
||||||
|
font-size: clamp(15px, 24rpx, 18px);
|
||||||
|
}
|
||||||
|
.editor-control input {
|
||||||
|
min-height: 82rpx;
|
||||||
|
padding: 0 28rpx;
|
||||||
|
}
|
||||||
|
.editor-control--textarea {
|
||||||
|
min-height: 132rpx;
|
||||||
|
padding: 20rpx 24rpx;
|
||||||
|
}
|
||||||
|
.editor-control--article {
|
||||||
|
min-height: 220rpx;
|
||||||
|
}
|
||||||
|
.editor-control--unavailable {
|
||||||
|
padding: 0 28rpx;
|
||||||
|
color: $ink-muted;
|
||||||
|
font-size: clamp(14px, 23rpx, 17px);
|
||||||
|
}
|
||||||
|
.editor-control textarea {
|
||||||
|
min-height: 92rpx;
|
||||||
|
line-height: 1.65;
|
||||||
|
}
|
||||||
|
.editor-control--article textarea {
|
||||||
|
min-height: 180rpx;
|
||||||
|
}
|
||||||
|
.editor-field--cover,
|
||||||
|
.editor-field--media {
|
||||||
|
display: grid;
|
||||||
|
gap: 12rpx;
|
||||||
|
padding: 18rpx 22rpx;
|
||||||
|
border: 1rpx solid rgba(128, 89, 49, 0.34);
|
||||||
|
border-radius: 12rpx;
|
||||||
|
background: rgba(255, 252, 245, 0.7);
|
||||||
|
}
|
||||||
|
.editor-field--cover .editor-field__label,
|
||||||
|
.editor-field--media .editor-field__label {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.editor-field__hint {
|
||||||
|
display: block;
|
||||||
|
color: $ink-muted;
|
||||||
|
font-size: clamp(13px, 20rpx, 16px);
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
.upload-button {
|
||||||
|
justify-self: start;
|
||||||
|
min-height: 88rpx;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0 20rpx;
|
||||||
|
border: 1rpx solid rgba(159, 23, 15, 0.42);
|
||||||
|
border-radius: 8rpx;
|
||||||
|
background: transparent;
|
||||||
|
color: $brand-red;
|
||||||
|
font-size: clamp(14px, 22rpx, 17px);
|
||||||
|
}
|
||||||
|
.upload-receipt {
|
||||||
|
color: $ink-muted;
|
||||||
|
font-size: clamp(13px, 21rpx, 16px);
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
.editor-control--password-confirm { margin-top: 14rpx; }
|
||||||
|
.remove-cover-button {
|
||||||
|
justify-self: start;
|
||||||
|
min-height: 72rpx;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0 18rpx;
|
||||||
|
border: 1rpx solid rgba($brand-red, 0.38);
|
||||||
|
border-radius: 8rpx;
|
||||||
|
background: transparent;
|
||||||
|
color: $brand-red;
|
||||||
|
font-size: clamp(13px, 21rpx, 16px);
|
||||||
|
}
|
||||||
|
.remove-cover-button::after { border: 0; }
|
||||||
|
.body-media-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 8rpx;
|
||||||
|
}
|
||||||
|
.body-media-item {
|
||||||
|
display: flex;
|
||||||
|
min-height: 66rpx;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16rpx;
|
||||||
|
padding: 8rpx 12rpx 8rpx 18rpx;
|
||||||
|
border: 1rpx solid rgba(128, 89, 49, 0.22);
|
||||||
|
border-radius: 8rpx;
|
||||||
|
background: rgba(255, 253, 248, 0.58);
|
||||||
|
}
|
||||||
|
.body-media-item > text {
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
color: $ink-muted;
|
||||||
|
font-size: clamp(13px, 21rpx, 16px);
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
.remove-media-button {
|
||||||
|
min-width: var(--app-touch-min);
|
||||||
|
min-height: var(--app-touch-min);
|
||||||
|
margin: 0;
|
||||||
|
padding: 0 14rpx;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: $brand-red;
|
||||||
|
font-size: clamp(13px, 20rpx, 16px);
|
||||||
|
}
|
||||||
|
.remove-media-button::after { border: 0; }
|
||||||
|
.editor-placeholder {
|
||||||
|
color: #9e8e79;
|
||||||
|
}
|
||||||
|
.editor-save-error {
|
||||||
|
display: block;
|
||||||
|
margin: 12rpx 8rpx 0;
|
||||||
|
color: $brand-red;
|
||||||
|
font-size: clamp(15px, 24rpx, 18px);
|
||||||
|
line-height: max(1.35em, clamp(19px, 34rpx, 24px));
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.editor-panel__body > .app-button {
|
||||||
|
margin-top: 22rpx;
|
||||||
|
}
|
||||||
|
.editor-result-card {
|
||||||
|
min-height: 420rpx;
|
||||||
|
}
|
||||||
|
.editor-result-card__body {
|
||||||
|
padding: 112rpx 52rpx 68rpx;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.editor-result-card__title {
|
||||||
|
display: block;
|
||||||
|
margin-top: 14rpx;
|
||||||
|
color: $ink;
|
||||||
|
font-family: STKaiti, KaiTi, serif;
|
||||||
|
font-size: clamp(19px, 36rpx, 24px);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.editor-result-card__copy {
|
||||||
|
display: block;
|
||||||
|
margin-top: 16rpx;
|
||||||
|
color: $ink-muted;
|
||||||
|
font-size: clamp(15px, 24rpx, 18px);
|
||||||
|
line-height: 1.65;
|
||||||
|
}
|
||||||
|
.editor-result-card__body > .app-button {
|
||||||
|
margin-top: 30rpx;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,398 @@
|
|||||||
|
<template>
|
||||||
|
<view class="article-list-page" :class="`article-list-state--${listState}`">
|
||||||
|
<ModulePageBackground module="family" />
|
||||||
|
<view class="article-list-header"
|
||||||
|
><PageHeader
|
||||||
|
title="谱文"
|
||||||
|
:action="hasValidContext ? '新建' : ''"
|
||||||
|
@action="createArticle"
|
||||||
|
/></view>
|
||||||
|
<view class="article-list-content">
|
||||||
|
<view v-if="articleCategoryError" class="article-category-error" role="alert">
|
||||||
|
<text>{{ articleCategoryError }}</text>
|
||||||
|
</view>
|
||||||
|
<view v-if="categoryOptions.length > 1" class="article-filter">
|
||||||
|
<text>文章分类</text>
|
||||||
|
<picker :range="categoryLabels" :value="categoryIndex" @change="selectCategory">
|
||||||
|
<view class="article-filter__value">{{ selectedCategoryLabel }} ›</view>
|
||||||
|
</picker>
|
||||||
|
</view>
|
||||||
|
<view v-if="listState === 'list'" class="article-list-items">
|
||||||
|
<BatchManagementBar
|
||||||
|
v-if="deletableArticles.length"
|
||||||
|
resource-name="谱文"
|
||||||
|
:active="articleBatch.selectionMode.value"
|
||||||
|
:selected-count="articleBatch.selectedCount.value"
|
||||||
|
:all-selected="articleBatch.allSelected.value"
|
||||||
|
:busy="articleBatch.deleting.value"
|
||||||
|
@start="articleBatch.enterSelectionMode"
|
||||||
|
@finish="articleBatch.exitSelectionMode"
|
||||||
|
@toggle-all="articleBatch.toggleAll"
|
||||||
|
@delete="articleBatch.requestDelete"
|
||||||
|
/>
|
||||||
|
<text v-if="articleBatch.notice.value" class="article-batch-notice" role="status">{{ articleBatch.notice.value }}</text>
|
||||||
|
<text v-if="articleBatch.error.value" class="article-batch-error" role="alert">{{ articleBatch.error.value }}</text>
|
||||||
|
<view
|
||||||
|
v-for="item in filteredArticles"
|
||||||
|
:key="item.id"
|
||||||
|
class="article-card"
|
||||||
|
@click="handleArticleClick(item)"
|
||||||
|
>
|
||||||
|
<BatchSelectionMark
|
||||||
|
v-if="articleBatch.selectionMode.value && item.canDelete"
|
||||||
|
:selected="articleBatch.isSelected(item)"
|
||||||
|
:label="`谱文:${item.title}`"
|
||||||
|
@toggle="articleBatch.toggleSelection(item)"
|
||||||
|
/>
|
||||||
|
<image
|
||||||
|
v-if="item.coverFile?.accessUrl"
|
||||||
|
class="article-card__cover"
|
||||||
|
:src="item.coverFile.accessUrl"
|
||||||
|
mode="aspectFill"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<text class="article-card__title">{{ item.title }}</text>
|
||||||
|
<text class="article-card__summary">{{
|
||||||
|
item.summary || item.content
|
||||||
|
}}</text>
|
||||||
|
<text class="article-card__meta"
|
||||||
|
>{{ item.category ? `${item.category} · ` : "" }}{{ item.author || "家族成员" }} ·
|
||||||
|
{{ item.time || "未标注时间" }}</text
|
||||||
|
>
|
||||||
|
</view>
|
||||||
|
<view v-if="!filteredArticles.length" class="article-filter-empty">
|
||||||
|
<text>这个分类下还没有谱文</text>
|
||||||
|
<text>可以切换其他分类查看。</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view v-else class="article-list-state-card">
|
||||||
|
<text>{{ stateCopy.title }}</text>
|
||||||
|
<text>{{ stateCopy.copy }}</text>
|
||||||
|
<AppButton
|
||||||
|
v-if="!hasValidContext || listState !== 'loading'"
|
||||||
|
block
|
||||||
|
:label="stateCopy.action"
|
||||||
|
@click="handleStateAction"
|
||||||
|
/>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<AppDialog
|
||||||
|
:visible="articleBatch.confirmationVisible.value"
|
||||||
|
:close-on-mask="false"
|
||||||
|
eyebrow="批量删除"
|
||||||
|
title="将选中的谱文移入回收站?"
|
||||||
|
:message="articleBatch.confirmationMessage.value"
|
||||||
|
:confirm-text="articleBatch.deleting.value ? '正在删除' : '移入回收站'"
|
||||||
|
cancel-text="继续选择"
|
||||||
|
show-cancel
|
||||||
|
@confirm="articleBatch.confirmDelete"
|
||||||
|
@cancel="articleBatch.cancelDelete"
|
||||||
|
/>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, ref } from "vue";
|
||||||
|
import { onLoad, onShow, onUnload } from "@dcloudio/uni-app";
|
||||||
|
import AppButton from "@/components/AppButton.vue";
|
||||||
|
import AppDialog from "@/components/AppDialog.vue";
|
||||||
|
import BatchManagementBar from "@/components/BatchManagementBar.vue";
|
||||||
|
import BatchSelectionMark from "@/components/BatchSelectionMark.vue";
|
||||||
|
import ModulePageBackground from "@/components/ModulePageBackground.vue";
|
||||||
|
import PageHeader from "@/components/PageHeader.vue";
|
||||||
|
import {
|
||||||
|
createRequestController,
|
||||||
|
isRequestCancelled
|
||||||
|
} from "@/services/api/request-controller.js";
|
||||||
|
import { getRequestErrorMessage } from "@/services/api/request-error-message.js";
|
||||||
|
import { familyArticleApi } from "@/services/api/family-article-service.js";
|
||||||
|
import { useBatchDeletion } from "@/composables/use-batch-deletion.js";
|
||||||
|
import { goBack, openPage } from "@/utils/navigation/gateway.js";
|
||||||
|
|
||||||
|
const genealogyId = ref("");
|
||||||
|
const hasValidContext = ref(false);
|
||||||
|
const listState = ref("loading");
|
||||||
|
const articles = ref([]);
|
||||||
|
const categories = ref([]);
|
||||||
|
const articleCategoryError = ref("");
|
||||||
|
const selectedCategoryId = ref("");
|
||||||
|
const articleListRequestController = createRequestController();
|
||||||
|
const articleCategoryRequestController = createRequestController();
|
||||||
|
const articleDeleteRequestController = createRequestController();
|
||||||
|
let pageActive = true;
|
||||||
|
let skipInitialShowRefresh = true;
|
||||||
|
const categoryOptions = computed(() => [
|
||||||
|
{ id: "", name: "全部分类" },
|
||||||
|
...categories.value.filter((item) => item.enabled),
|
||||||
|
]);
|
||||||
|
const categoryLabels = computed(() => categoryOptions.value.map((item) => item.name));
|
||||||
|
const categoryIndex = computed(() =>
|
||||||
|
Math.max(0, categoryOptions.value.findIndex((item) => item.id === selectedCategoryId.value)),
|
||||||
|
);
|
||||||
|
const selectedCategoryLabel = computed(() => categoryOptions.value[categoryIndex.value]?.name || "全部分类");
|
||||||
|
const filteredArticles = computed(() =>
|
||||||
|
selectedCategoryId.value
|
||||||
|
? articles.value.filter((item) => item.categoryId === selectedCategoryId.value)
|
||||||
|
: articles.value,
|
||||||
|
);
|
||||||
|
const articleBatch = useBatchDeletion({
|
||||||
|
items: articles,
|
||||||
|
visibleItems: filteredArticles,
|
||||||
|
deleteOne: (article) =>
|
||||||
|
familyArticleApi.deleteArticle(genealogyId.value, article.id, {
|
||||||
|
requestController: articleDeleteRequestController,
|
||||||
|
}),
|
||||||
|
resourceName: "谱文",
|
||||||
|
isActive: () => pageActive,
|
||||||
|
onEmpty: () => {
|
||||||
|
listState.value = "empty";
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const deletableArticles = articleBatch.deletableItems;
|
||||||
|
const stateCopy = computed(() =>
|
||||||
|
hasValidContext.value
|
||||||
|
? listState.value === "empty"
|
||||||
|
? {
|
||||||
|
title: "还没有谱文",
|
||||||
|
copy: "新建第一篇谱文,记录值得传承的故事。",
|
||||||
|
action: "新建谱文",
|
||||||
|
}
|
||||||
|
: listState.value === "error"
|
||||||
|
? {
|
||||||
|
title: "谱文暂时无法读取",
|
||||||
|
copy: "请检查网络后重新读取。",
|
||||||
|
action: "重新读取",
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
title: "正在读取谱文",
|
||||||
|
copy: "请稍候。",
|
||||||
|
action: "重新读取",
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
title: "暂时无法打开谱文",
|
||||||
|
copy: "未找到家谱信息,请返回后重新进入。",
|
||||||
|
action: "返回上一页",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
onLoad((query) => {
|
||||||
|
genealogyId.value = String(query?.genealogyId || "");
|
||||||
|
hasValidContext.value = /^[1-9]\d*$/.test(genealogyId.value);
|
||||||
|
if (hasValidContext.value) void loadArticles();
|
||||||
|
else listState.value = "invalid";
|
||||||
|
});
|
||||||
|
onShow(() => {
|
||||||
|
if (skipInitialShowRefresh) {
|
||||||
|
skipInitialShowRefresh = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (hasValidContext.value) void loadArticles();
|
||||||
|
});
|
||||||
|
onUnload(() => {
|
||||||
|
pageActive = false;
|
||||||
|
articleListRequestController.abort();
|
||||||
|
articleCategoryRequestController.abort();
|
||||||
|
articleDeleteRequestController.abort();
|
||||||
|
});
|
||||||
|
const loadArticleCategories = async () => {
|
||||||
|
articleCategoryError.value = "";
|
||||||
|
try {
|
||||||
|
return await familyArticleApi.getArticleCategories(genealogyId.value, {
|
||||||
|
requestController: articleCategoryRequestController,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (isRequestCancelled(error)) throw error;
|
||||||
|
articleCategoryError.value = getRequestErrorMessage(
|
||||||
|
error,
|
||||||
|
"谱文分类暂时无法读取,当前仍可查看全部谱文。",
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const loadArticles = async () => {
|
||||||
|
articleListRequestController.abort();
|
||||||
|
articleCategoryRequestController.abort();
|
||||||
|
listState.value = "loading";
|
||||||
|
try {
|
||||||
|
const [rows, categoryRows] = await Promise.all([
|
||||||
|
familyArticleApi.getArticles(genealogyId.value, {
|
||||||
|
requestController: articleListRequestController,
|
||||||
|
}),
|
||||||
|
loadArticleCategories(),
|
||||||
|
]);
|
||||||
|
if (!pageActive) return;
|
||||||
|
articles.value = rows;
|
||||||
|
articleBatch.exitSelectionMode();
|
||||||
|
if (categoryRows) categories.value = categoryRows;
|
||||||
|
if (!categoryOptions.value.some((item) => item.id === selectedCategoryId.value)) {
|
||||||
|
selectedCategoryId.value = "";
|
||||||
|
}
|
||||||
|
listState.value = articles.value.length ? "list" : "empty";
|
||||||
|
} catch (error) {
|
||||||
|
if (!pageActive || isRequestCancelled(error)) return;
|
||||||
|
listState.value = "error";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const selectCategory = (event) => {
|
||||||
|
selectedCategoryId.value = categoryOptions.value[Number(event.detail.value)]?.id || "";
|
||||||
|
articleBatch.exitSelectionMode();
|
||||||
|
};
|
||||||
|
const createArticle = () =>
|
||||||
|
hasValidContext.value
|
||||||
|
? openPage("F06", { genealogyId: genealogyId.value, mode: "create" }, "F04")
|
||||||
|
: Promise.resolve(false);
|
||||||
|
const openArticle = (item) =>
|
||||||
|
openPage("F05", { genealogyId: genealogyId.value, articleId: item.id }, "F04");
|
||||||
|
const handleArticleClick = (item) => {
|
||||||
|
if (articleBatch.selectionMode.value && item?.canDelete) {
|
||||||
|
articleBatch.toggleSelection(item);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
openArticle(item);
|
||||||
|
};
|
||||||
|
const handleStateAction = () => {
|
||||||
|
if (!hasValidContext.value) return goBack();
|
||||||
|
if (listState.value === "error") return loadArticles();
|
||||||
|
if (listState.value === "empty") return createArticle();
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
@import "../../styles/adaptive-frame-profiles.scss";
|
||||||
|
.article-list-page {
|
||||||
|
display: flex;
|
||||||
|
min-height: 100vh;
|
||||||
|
flex-direction: column;
|
||||||
|
background: $paper;
|
||||||
|
}
|
||||||
|
.article-list-header,
|
||||||
|
.article-list-content {
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
.article-list-content {
|
||||||
|
padding: 18rpx 24rpx calc(72rpx + env(safe-area-inset-bottom));
|
||||||
|
}
|
||||||
|
.article-list-items {
|
||||||
|
margin-top: 24rpx;
|
||||||
|
}
|
||||||
|
.article-batch-notice,
|
||||||
|
.article-batch-error {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 16rpx;
|
||||||
|
font-size: clamp(13px, 21rpx, 16px);
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
.article-batch-notice { color: #426538; }
|
||||||
|
.article-batch-error { color: $brand-red; }
|
||||||
|
.article-category-error {
|
||||||
|
margin-top: 20rpx;
|
||||||
|
padding: 18rpx 22rpx;
|
||||||
|
border: 1rpx solid rgba(159, 35, 35, 0.22);
|
||||||
|
background: rgba(255, 247, 233, 0.94);
|
||||||
|
color: $brand-red;
|
||||||
|
font-size: clamp(13px, 22rpx, 16px);
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
.article-filter {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-top: 20rpx;
|
||||||
|
padding: 22rpx 26rpx;
|
||||||
|
border: 1rpx solid rgba(159, 35, 35, 0.18);
|
||||||
|
background: rgba(255, 252, 242, 0.92);
|
||||||
|
color: $ink;
|
||||||
|
font-size: clamp(14px, 24rpx, 17px);
|
||||||
|
min-height: 80rpx;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
.article-filter__value {
|
||||||
|
color: $brand-red;
|
||||||
|
}
|
||||||
|
.article-filter-empty {
|
||||||
|
padding: 64rpx 24rpx;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.article-filter-empty text {
|
||||||
|
display: block;
|
||||||
|
color: $ink-muted;
|
||||||
|
font-size: clamp(14px, 24rpx, 17px);
|
||||||
|
line-height: 1.7;
|
||||||
|
}
|
||||||
|
.article-filter-empty text:first-child {
|
||||||
|
color: $ink;
|
||||||
|
font-size: clamp(17px, 30rpx, 21px);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.article-card {
|
||||||
|
@include adaptive-family-content;
|
||||||
|
display: flex;
|
||||||
|
min-height: 150rpx;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin-bottom: 16rpx;
|
||||||
|
padding: 28rpx;
|
||||||
|
background-color: rgba($paper, 0.82);
|
||||||
|
}
|
||||||
|
.article-card__cover {
|
||||||
|
width: 100%;
|
||||||
|
height: 280rpx;
|
||||||
|
margin-bottom: 20rpx;
|
||||||
|
border-radius: 10rpx;
|
||||||
|
background: rgba(128, 89, 49, 0.12);
|
||||||
|
}
|
||||||
|
.article-card text {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.article-card__title {
|
||||||
|
color: $ink;
|
||||||
|
font-family: STKaiti, KaiTi, serif;
|
||||||
|
font-size: clamp(17px, 31rpx, 22px);
|
||||||
|
font-weight: 700;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
.article-card__summary {
|
||||||
|
display: -webkit-box;
|
||||||
|
margin-top: 9rpx;
|
||||||
|
overflow: hidden;
|
||||||
|
color: $ink-muted;
|
||||||
|
font-size: clamp(15px, 24rpx, 18px);
|
||||||
|
line-height: 1.45;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
}
|
||||||
|
.article-card__meta {
|
||||||
|
margin-top: 10rpx;
|
||||||
|
color: $brand-red;
|
||||||
|
font-size: clamp(13px, 21rpx, 16px);
|
||||||
|
}
|
||||||
|
.article-list-state-card {
|
||||||
|
@include adaptive-family-content;
|
||||||
|
background-color: rgba($paper, 0.82);
|
||||||
|
width: 100%;
|
||||||
|
min-height: 340rpx;
|
||||||
|
margin-top: 30rpx;
|
||||||
|
padding: 78rpx 52rpx 50rpx;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.article-list-state-card > text {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.article-list-state-card > text:first-child {
|
||||||
|
color: $ink;
|
||||||
|
font-family: STKaiti, KaiTi, serif;
|
||||||
|
font-size: clamp(19px, 35rpx, 24px);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.article-list-state-card > text:nth-child(2) {
|
||||||
|
margin-top: 18rpx;
|
||||||
|
color: $ink-muted;
|
||||||
|
font-size: clamp(15px, 24rpx, 18px);
|
||||||
|
line-height: 1.65;
|
||||||
|
}
|
||||||
|
.article-list-state-card .app-button {
|
||||||
|
margin-top: 28rpx;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,418 @@
|
|||||||
|
<template>
|
||||||
|
<view class="feed-detail-page" :class="`feed-state--${feedState}`">
|
||||||
|
<ModulePageBackground module="family" />
|
||||||
|
<view class="feed-detail-header"
|
||||||
|
><PageHeader title="动态详情" custom-back @back="requestBack"
|
||||||
|
/></view>
|
||||||
|
|
||||||
|
<view class="feed-detail-content">
|
||||||
|
<AppLoading
|
||||||
|
v-if="feedState === 'loading'"
|
||||||
|
text="正在读取动态"
|
||||||
|
description="请稍候,正在同步家族动态与评论。"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<view v-else-if="feedState === 'ready'" class="feed-detail-body">
|
||||||
|
<view class="feed-card">
|
||||||
|
<view class="feed-card__heading">
|
||||||
|
<text>{{ feedTypeLabel(feed.type) }}</text>
|
||||||
|
<text>{{ formatMinuteTimestamp(feed.time) || "未标注时间" }}</text>
|
||||||
|
</view>
|
||||||
|
<text class="feed-card__content">{{ feed.content }}</text>
|
||||||
|
<FamilyFeedMedia :files="feed.mediaFiles" />
|
||||||
|
<view class="feed-card__meta">
|
||||||
|
<text>发布:{{ feed.publisher }}</text>
|
||||||
|
<text
|
||||||
|
>{{ feed.likeCount }} 次点赞 ·
|
||||||
|
{{ feed.commentCount }} 条评论</text
|
||||||
|
>
|
||||||
|
</view>
|
||||||
|
<view class="feed-card__actions">
|
||||||
|
<AppButton
|
||||||
|
compact
|
||||||
|
type="secondary"
|
||||||
|
:disabled="isTogglingLike || !hasLikeState"
|
||||||
|
:label="
|
||||||
|
isTogglingLike
|
||||||
|
? '正在提交'
|
||||||
|
: hasLikeState
|
||||||
|
? feed.likedByMe
|
||||||
|
? '取消点赞'
|
||||||
|
: '点赞'
|
||||||
|
: '点赞不可用'
|
||||||
|
"
|
||||||
|
@click="toggleLike"
|
||||||
|
/>
|
||||||
|
<AppButton
|
||||||
|
v-if="feed.canEdit"
|
||||||
|
compact
|
||||||
|
type="secondary"
|
||||||
|
label="编辑动态"
|
||||||
|
@click="openEditFeed"
|
||||||
|
/>
|
||||||
|
<AppButton
|
||||||
|
v-if="feed.canDelete"
|
||||||
|
compact
|
||||||
|
type="secondary"
|
||||||
|
label="删除动态"
|
||||||
|
@click="requestDeleteFeed"
|
||||||
|
/>
|
||||||
|
</view>
|
||||||
|
<text v-if="!hasLikeState" class="like-note"
|
||||||
|
>暂时无法确认是否已点赞,请稍后再试。</text
|
||||||
|
>
|
||||||
|
<text v-if="likeError" class="like-error">{{ likeError }}</text>
|
||||||
|
<text v-if="deleteError" class="delete-error">{{ deleteError }}</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<FeedCommentSection
|
||||||
|
:genealogy-id="genealogyId"
|
||||||
|
:feed-id="feedId"
|
||||||
|
:refresh-feed-summary="refreshFeedSummary"
|
||||||
|
/>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view v-else class="feed-state-card">
|
||||||
|
<text>{{ stateCopy.title }}</text>
|
||||||
|
<text>{{ stateCopy.copy }}</text>
|
||||||
|
<AppButton
|
||||||
|
type="secondary"
|
||||||
|
block
|
||||||
|
:label="stateCopy.action"
|
||||||
|
@click="handleStateAction"
|
||||||
|
/>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<AppDialog
|
||||||
|
:visible="deleteConfirmationVisible"
|
||||||
|
:close-on-mask="false"
|
||||||
|
eyebrow="删除确认"
|
||||||
|
title="将这条动态移至回收站?"
|
||||||
|
message="移入回收站后不再展示,家谱管理员可在保留期内恢复。"
|
||||||
|
confirm-text="移至回收站"
|
||||||
|
cancel-text="保留动态"
|
||||||
|
show-cancel
|
||||||
|
@confirm="deleteFeed"
|
||||||
|
@cancel="closeDeleteConfirmation"
|
||||||
|
/>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, ref } from "vue";
|
||||||
|
import { onBackPress, onLoad, onShow, onUnload } from "@dcloudio/uni-app";
|
||||||
|
import AppButton from "@/components/AppButton.vue";
|
||||||
|
import AppDialog from "@/components/AppDialog.vue";
|
||||||
|
import AppLoading from "@/components/AppLoading.vue";
|
||||||
|
import FeedCommentSection from "@/components/family/FeedCommentSection.vue";
|
||||||
|
import FamilyFeedMedia from "@/components/family/FeedMedia.vue";
|
||||||
|
import ModulePageBackground from "@/components/ModulePageBackground.vue";
|
||||||
|
import PageHeader from "@/components/PageHeader.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 { goBack, handleBackPress, openPage, returnTo } from "@/utils/navigation/gateway.js";
|
||||||
|
|
||||||
|
const genealogyId = ref("");
|
||||||
|
const feedId = ref("");
|
||||||
|
const feedState = ref("loading");
|
||||||
|
const feed = ref(null);
|
||||||
|
const isTogglingLike = ref(false);
|
||||||
|
const likeError = ref("");
|
||||||
|
const deleteConfirmationVisible = ref(false);
|
||||||
|
const deleting = ref(false);
|
||||||
|
const deleteError = ref("");
|
||||||
|
const feedReadController = createRequestController();
|
||||||
|
const feedLikeRequestController = createRequestController();
|
||||||
|
const feedDeletionRequestController = createRequestController();
|
||||||
|
let pageActive = true;
|
||||||
|
let skipInitialShowRefresh = true;
|
||||||
|
const feedTypeLabel = (type) =>
|
||||||
|
String(type || "")
|
||||||
|
.trim()
|
||||||
|
.toLowerCase() === "text"
|
||||||
|
? "文字动态"
|
||||||
|
: "家族动态";
|
||||||
|
|
||||||
|
const hasValidContext = computed(
|
||||||
|
() => /^[1-9]\d*$/.test(genealogyId.value) && /^[1-9]\d*$/.test(feedId.value),
|
||||||
|
);
|
||||||
|
const hasLikeState = computed(() => typeof feed.value?.likedByMe === "boolean");
|
||||||
|
const stateCopy = computed(() => {
|
||||||
|
if (!hasValidContext.value) {
|
||||||
|
return {
|
||||||
|
title: "暂时无法打开动态",
|
||||||
|
copy: "未找到家谱或动态信息,请返回后重新进入。",
|
||||||
|
action: "返回上一页",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (feedState.value === "missing") {
|
||||||
|
return {
|
||||||
|
title: "该动态已不存在",
|
||||||
|
copy: "它可能已被发布者删除,或你暂时无法查看。",
|
||||||
|
action: "返回家族动态",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
title: "动态暂时无法读取",
|
||||||
|
copy: "请检查网络后重新读取。",
|
||||||
|
action: "重新读取",
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const loadFeed = async ({ preserveContent = false } = {}) => {
|
||||||
|
if (!hasValidContext.value) {
|
||||||
|
feedState.value = "invalid";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!preserveContent) feedState.value = "loading";
|
||||||
|
try {
|
||||||
|
const current = await familyFeedApi.getFeedDetail(
|
||||||
|
genealogyId.value,
|
||||||
|
feedId.value,
|
||||||
|
{ requestController: feedReadController },
|
||||||
|
);
|
||||||
|
if (!pageActive) return;
|
||||||
|
feed.value = current;
|
||||||
|
feedState.value = "ready";
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
if (!pageActive || isRequestCancelled(error)) return null;
|
||||||
|
if (!preserveContent) {
|
||||||
|
feedState.value =
|
||||||
|
error?.code === "HTTP_ERROR" && error?.httpStatus === 404
|
||||||
|
? "missing"
|
||||||
|
: "error";
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const refresh = () => loadFeed();
|
||||||
|
const refreshFeedSummary = () => loadFeed({ preserveContent: true });
|
||||||
|
|
||||||
|
const toggleLike = async () => {
|
||||||
|
if (
|
||||||
|
isTogglingLike.value ||
|
||||||
|
!hasValidContext.value ||
|
||||||
|
!feed.value ||
|
||||||
|
!hasLikeState.value
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
const nextLiked = !feed.value.likedByMe;
|
||||||
|
isTogglingLike.value = true;
|
||||||
|
likeError.value = "";
|
||||||
|
try {
|
||||||
|
await familyFeedApi.setFeedLike(genealogyId.value, feedId.value, nextLiked, {
|
||||||
|
requestController: feedLikeRequestController,
|
||||||
|
});
|
||||||
|
if (!pageActive) return;
|
||||||
|
const feedRefreshed = await refreshFeedSummary();
|
||||||
|
if (feedRefreshed === false) {
|
||||||
|
likeError.value = "点赞已提交,最新状态暂时无法读取。";
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (!pageActive || isRequestCancelled(error)) return;
|
||||||
|
likeError.value = getRequestErrorMessage(error, "点赞操作失败,请稍后重试");
|
||||||
|
} finally {
|
||||||
|
if (pageActive) isTogglingLike.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const requestDeleteFeed = () => {
|
||||||
|
if (!feed.value?.canDelete || deleting.value) return;
|
||||||
|
deleteError.value = "";
|
||||||
|
deleteConfirmationVisible.value = true;
|
||||||
|
};
|
||||||
|
const closeDeleteConfirmation = () => {
|
||||||
|
if (!deleting.value) deleteConfirmationVisible.value = false;
|
||||||
|
};
|
||||||
|
const openEditFeed = () => {
|
||||||
|
if (!feed.value?.canEdit || !hasValidContext.value) return;
|
||||||
|
openPage(
|
||||||
|
"F02",
|
||||||
|
{ genealogyId: genealogyId.value, mode: "edit", feedId: feedId.value },
|
||||||
|
"F03",
|
||||||
|
);
|
||||||
|
};
|
||||||
|
const deleteFeed = async () => {
|
||||||
|
if (!feed.value?.canDelete || deleting.value) return;
|
||||||
|
deleting.value = true;
|
||||||
|
deleteError.value = "";
|
||||||
|
let deletionCommitted = false;
|
||||||
|
try {
|
||||||
|
await familyFeedApi.deleteFeed(genealogyId.value, feedId.value, {
|
||||||
|
requestController: feedDeletionRequestController,
|
||||||
|
});
|
||||||
|
deletionCommitted = true;
|
||||||
|
if (!pageActive) return;
|
||||||
|
deleteConfirmationVisible.value = false;
|
||||||
|
feed.value = null;
|
||||||
|
feedState.value = "missing";
|
||||||
|
await backToFamily();
|
||||||
|
} catch (error) {
|
||||||
|
if (!pageActive) return;
|
||||||
|
if (deletionCommitted) {
|
||||||
|
deleteConfirmationVisible.value = false;
|
||||||
|
feed.value = null;
|
||||||
|
feedState.value = "missing";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isRequestCancelled(error)) return;
|
||||||
|
deleteError.value = getRequestErrorMessage(error, "动态删除失败,请稍后重试。");
|
||||||
|
deleteConfirmationVisible.value = false;
|
||||||
|
} finally {
|
||||||
|
if (pageActive) deleting.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
onLoad((query) => {
|
||||||
|
genealogyId.value = String(query?.genealogyId || "");
|
||||||
|
feedId.value = String(query?.feedId || "");
|
||||||
|
void refresh();
|
||||||
|
});
|
||||||
|
onShow(() => {
|
||||||
|
if (skipInitialShowRefresh) {
|
||||||
|
skipInitialShowRefresh = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (hasValidContext.value) void refresh();
|
||||||
|
});
|
||||||
|
onUnload(() => {
|
||||||
|
pageActive = false;
|
||||||
|
feedReadController.abort();
|
||||||
|
feedLikeRequestController.abort();
|
||||||
|
feedDeletionRequestController.abort();
|
||||||
|
});
|
||||||
|
|
||||||
|
const backToFamily = () =>
|
||||||
|
hasValidContext.value
|
||||||
|
? returnTo("F12", { genealogyId: genealogyId.value })
|
||||||
|
: goBack();
|
||||||
|
const requestBack = () => backToFamily();
|
||||||
|
const handleStateAction = () =>
|
||||||
|
feedState.value === "error" ? refresh() : backToFamily();
|
||||||
|
onBackPress((event) => handleBackPress(event, requestBack));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
@import "../../styles/adaptive-frame-profiles.scss";
|
||||||
|
|
||||||
|
.feed-detail-page {
|
||||||
|
display: flex;
|
||||||
|
min-height: 100vh;
|
||||||
|
flex-direction: column;
|
||||||
|
background: $paper;
|
||||||
|
}
|
||||||
|
.feed-detail-header,
|
||||||
|
.feed-detail-content {
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
.feed-detail-content {
|
||||||
|
flex: 1;
|
||||||
|
padding: 18rpx 24rpx calc(72rpx + env(safe-area-inset-bottom));
|
||||||
|
}
|
||||||
|
.feed-detail-body {
|
||||||
|
margin-top: 18rpx;
|
||||||
|
}
|
||||||
|
.feed-card,
|
||||||
|
.feed-state-card {
|
||||||
|
@include adaptive-family-content;
|
||||||
|
box-sizing: border-box;
|
||||||
|
background-color: rgba($paper, 0.82);
|
||||||
|
}
|
||||||
|
.feed-card {
|
||||||
|
padding: 30rpx;
|
||||||
|
}
|
||||||
|
.feed-card__heading,
|
||||||
|
.feed-card__meta {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 18rpx;
|
||||||
|
}
|
||||||
|
.feed-card__heading text:first-child {
|
||||||
|
color: $brand-red;
|
||||||
|
font-size: clamp(14px, 23rpx, 17px);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.feed-card__heading text:last-child {
|
||||||
|
color: $ink-muted;
|
||||||
|
font-size: clamp(13px, 21rpx, 16px);
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
.feed-card__content {
|
||||||
|
display: block;
|
||||||
|
margin-top: 20rpx;
|
||||||
|
color: $ink;
|
||||||
|
font-size: clamp(16px, 29rpx, 20px);
|
||||||
|
line-height: 1.7;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
.feed-card__meta {
|
||||||
|
margin-top: 22rpx;
|
||||||
|
color: $ink-muted;
|
||||||
|
font-size: clamp(13px, 21rpx, 16px);
|
||||||
|
}
|
||||||
|
.feed-card .app-button {
|
||||||
|
margin-top: 18rpx;
|
||||||
|
}
|
||||||
|
.feed-card__actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10rpx;
|
||||||
|
}
|
||||||
|
.feed-card__actions .app-button {
|
||||||
|
width: auto;
|
||||||
|
min-width: 180rpx;
|
||||||
|
flex: 1 1 180rpx;
|
||||||
|
margin-top: 18rpx;
|
||||||
|
}
|
||||||
|
.like-note,
|
||||||
|
.like-error {
|
||||||
|
display: block;
|
||||||
|
margin-top: 10rpx;
|
||||||
|
font-size: clamp(14px, 22rpx, 17px);
|
||||||
|
}
|
||||||
|
.like-note {
|
||||||
|
color: $ink-muted;
|
||||||
|
}
|
||||||
|
.like-error {
|
||||||
|
color: $brand-red;
|
||||||
|
}
|
||||||
|
.delete-error {
|
||||||
|
display: block;
|
||||||
|
margin-top: 10rpx;
|
||||||
|
color: $brand-red;
|
||||||
|
font-size: clamp(14px, 22rpx, 17px);
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
.feed-state-card {
|
||||||
|
min-height: 340rpx;
|
||||||
|
margin-top: 36rpx;
|
||||||
|
padding: 78rpx 52rpx 50rpx;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.feed-state-card > text {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.feed-state-card > text:first-child {
|
||||||
|
color: $ink;
|
||||||
|
font-family: STKaiti, KaiTi, serif;
|
||||||
|
font-size: clamp(19px, 35rpx, 24px);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.feed-state-card > text:nth-child(2) {
|
||||||
|
margin-top: 18rpx;
|
||||||
|
color: $ink-muted;
|
||||||
|
font-size: clamp(15px, 24rpx, 18px);
|
||||||
|
line-height: 1.65;
|
||||||
|
}
|
||||||
|
.feed-state-card .app-button {
|
||||||
|
margin-top: 30rpx;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,633 @@
|
|||||||
|
<template>
|
||||||
|
<view class="publish-page" :class="`publish-state--${publishState}`">
|
||||||
|
<ModulePageBackground module="family" />
|
||||||
|
<view class="publish-page__header"
|
||||||
|
><PageHeader :title="isEdit ? '编辑动态' : '发布动态'" custom-back @back="requestBack"
|
||||||
|
/></view>
|
||||||
|
<view class="publish-panel">
|
||||||
|
<view v-if="publishState === 'form'" class="publish-form">
|
||||||
|
<text>{{ isEdit ? '编辑此刻' : '记录此刻' }}</text>
|
||||||
|
<text>{{ isEdit ? '原有图片和相关设置会保留。' : '填写要发布的内容,其余项目可按需补充。' }}</text>
|
||||||
|
|
||||||
|
<view class="publish-field publish-field--content">
|
||||||
|
<text class="publish-field__label"
|
||||||
|
><text class="required-mark">*</text>动态内容</text
|
||||||
|
>
|
||||||
|
<textarea
|
||||||
|
v-model="form.feedContent"
|
||||||
|
auto-height
|
||||||
|
placeholder="写下想对家人说的话"
|
||||||
|
placeholder-class="publish-placeholder"
|
||||||
|
@input="submitError = ''"
|
||||||
|
/>
|
||||||
|
</view>
|
||||||
|
<view class="publish-field">
|
||||||
|
<text class="publish-field__label">动态类型</text>
|
||||||
|
<view class="publish-field__value publish-field__value--readonly">
|
||||||
|
<text>文字动态</text>
|
||||||
|
<text>当前仅支持此类型</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view class="publish-field publish-field--media">
|
||||||
|
<view>
|
||||||
|
<text class="publish-field__label">动态配图</text>
|
||||||
|
<text class="publish-field__hint"
|
||||||
|
>{{ isEdit ? "可新增、预览或移除图片,保存后生效。" : "图片上传成功后,会随动态一起发布。" }}</text
|
||||||
|
>
|
||||||
|
</view>
|
||||||
|
<button
|
||||||
|
class="upload-button"
|
||||||
|
:disabled="isUploading || isSubmitting"
|
||||||
|
@click="uploadImage"
|
||||||
|
>
|
||||||
|
{{ isUploading ? "上传中…" : "添加图片" }}
|
||||||
|
</button>
|
||||||
|
<template v-if="isEdit">
|
||||||
|
<view v-if="mediaReceipts.length" class="media-preview-grid">
|
||||||
|
<view
|
||||||
|
v-for="(receipt, index) in mediaReceipts"
|
||||||
|
:key="`${receipt.ossId}-${index}`"
|
||||||
|
class="media-preview-card"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
class="media-preview-card__preview"
|
||||||
|
:disabled="isUploading || isSubmitting || !getMediaPreviewUrl(receipt)"
|
||||||
|
:aria-label="`预览第${index + 1}张动态配图`"
|
||||||
|
@click="previewMedia(receipt)"
|
||||||
|
>
|
||||||
|
<image
|
||||||
|
v-if="getMediaPreviewUrl(receipt)"
|
||||||
|
class="media-preview-card__image"
|
||||||
|
:src="getMediaPreviewUrl(receipt)"
|
||||||
|
mode="aspectFill"
|
||||||
|
/>
|
||||||
|
<view v-else class="media-preview-card__fallback">
|
||||||
|
<text>图片暂不可预览</text>
|
||||||
|
</view>
|
||||||
|
</button>
|
||||||
|
<view class="media-preview-card__meta">
|
||||||
|
<text>{{ receipt.fileName || `动态配图${index + 1}` }}</text>
|
||||||
|
<button
|
||||||
|
class="media-preview-card__remove"
|
||||||
|
:disabled="isUploading || isSubmitting"
|
||||||
|
:aria-label="`移除第${index + 1}张动态配图`"
|
||||||
|
@click="removeMedia(index)"
|
||||||
|
>移除</button>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<text
|
||||||
|
v-for="(receipt, index) in mediaReceipts"
|
||||||
|
:key="`${receipt.ossId}-${index}`"
|
||||||
|
class="upload-receipt"
|
||||||
|
>已上传:{{ receipt.fileName || "图片" }}</text
|
||||||
|
>
|
||||||
|
</template>
|
||||||
|
<text v-if="uploadError" class="publish-error">{{
|
||||||
|
uploadError
|
||||||
|
}}</text>
|
||||||
|
</view>
|
||||||
|
<text v-if="submitError" class="publish-error">{{ submitError }}</text>
|
||||||
|
<AppButton
|
||||||
|
block
|
||||||
|
:label="isSubmitting ? '正在提交' : isEdit ? '保存动态' : '提交动态'"
|
||||||
|
:disabled="isSubmitting || isUploading"
|
||||||
|
@click="saveFeed"
|
||||||
|
/>
|
||||||
|
</view>
|
||||||
|
<view v-else-if="publishState === 'loading'" class="publish-result"
|
||||||
|
><AppLoading text="正在读取动态"
|
||||||
|
/></view>
|
||||||
|
<view v-else class="publish-result">
|
||||||
|
<text>{{ resultCopy.title }}</text>
|
||||||
|
<text>{{ resultCopy.copy }}</text>
|
||||||
|
<AppButton
|
||||||
|
block
|
||||||
|
:label="resultCopy.action"
|
||||||
|
@click="handleResultAction"
|
||||||
|
/>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<AppDialog
|
||||||
|
:visible="discardVisible"
|
||||||
|
title="放弃动态草稿?"
|
||||||
|
message="动态还没有发布,确认返回后将不会保留。"
|
||||||
|
confirm-text="放弃并返回"
|
||||||
|
cancel-text="继续填写"
|
||||||
|
show-cancel
|
||||||
|
:close-on-mask="false"
|
||||||
|
@confirm="confirmDiscard"
|
||||||
|
@cancel="cancelDiscard"
|
||||||
|
/>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, reactive, ref } from "vue";
|
||||||
|
import { onBackPress, onLoad, onUnload } from "@dcloudio/uni-app";
|
||||||
|
import AppButton from "@/components/AppButton.vue";
|
||||||
|
import AppDialog from "@/components/AppDialog.vue";
|
||||||
|
import AppLoading from "@/components/AppLoading.vue";
|
||||||
|
import ModulePageBackground from "@/components/ModulePageBackground.vue";
|
||||||
|
import PageHeader from "@/components/PageHeader.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 { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
|
||||||
|
import { createNonIdempotentWriteGuard } from "@/utils/non-idempotent-write-guard.js";
|
||||||
|
import {
|
||||||
|
isImagePickCancelled,
|
||||||
|
pickAndUploadImage,
|
||||||
|
} from "@/utils/media-upload.js";
|
||||||
|
import {
|
||||||
|
goBack,
|
||||||
|
handleBackPress,
|
||||||
|
returnTo,
|
||||||
|
runBackGuard,
|
||||||
|
} from "@/utils/navigation/gateway.js";
|
||||||
|
|
||||||
|
const genealogyId = ref("");
|
||||||
|
const form = reactive({ feedContent: "", feedType: "text" });
|
||||||
|
const mediaReceipts = ref([]);
|
||||||
|
const publishState = ref("loading");
|
||||||
|
const isSubmitting = ref(false);
|
||||||
|
const isUploading = ref(false);
|
||||||
|
const submitError = ref("");
|
||||||
|
const uploadError = ref("");
|
||||||
|
const discardVisible = ref(false);
|
||||||
|
const editingFeed = ref(null);
|
||||||
|
const editFeedId = ref("");
|
||||||
|
const formBaseline = ref("");
|
||||||
|
const submittedEdit = ref(false);
|
||||||
|
const feedDetailController = createRequestController();
|
||||||
|
const feedMediaUploadController = createRequestController();
|
||||||
|
const feedSaveController = createRequestController();
|
||||||
|
const feedCreateGuard = createNonIdempotentWriteGuard();
|
||||||
|
let pageActive = true;
|
||||||
|
const mediaOssIds = computed(() =>
|
||||||
|
mediaReceipts.value.map((item) => item.ossId).join(","),
|
||||||
|
);
|
||||||
|
const isEdit = computed(() => Boolean(editingFeed.value));
|
||||||
|
const formSnapshot = computed(() =>
|
||||||
|
JSON.stringify({ ...form, mediaOssIds: mediaOssIds.value }),
|
||||||
|
);
|
||||||
|
const isDirty = computed(() =>
|
||||||
|
isEdit.value
|
||||||
|
? Boolean(formBaseline.value) && formSnapshot.value !== formBaseline.value
|
||||||
|
: Boolean(form.feedContent.trim() || mediaReceipts.value.length),
|
||||||
|
);
|
||||||
|
const hasValidContext = computed(() => /^[1-9]\d*$/.test(genealogyId.value));
|
||||||
|
const resultCopy = computed(
|
||||||
|
() =>
|
||||||
|
({
|
||||||
|
success: {
|
||||||
|
title: submittedEdit.value ? "动态已更新" : "动态发布成功",
|
||||||
|
copy: "已保存,返回后会显示最新内容。",
|
||||||
|
action: submittedEdit.value ? "返回动态详情" : "返回家族动态",
|
||||||
|
},
|
||||||
|
error: {
|
||||||
|
title: "动态未提交",
|
||||||
|
copy: submitError.value || "当前内容仍保留在页面中,可返回表单继续核对。",
|
||||||
|
action: "返回填写",
|
||||||
|
},
|
||||||
|
invalid: {
|
||||||
|
title: "暂时无法打开动态",
|
||||||
|
copy: "未找到家谱信息,请返回家谱首页后重新进入。",
|
||||||
|
action: "返回上一页",
|
||||||
|
},
|
||||||
|
unavailable: {
|
||||||
|
title: "暂时无法编辑动态",
|
||||||
|
copy: "这条动态已经变化,暂未保存。",
|
||||||
|
action: "返回动态详情",
|
||||||
|
},
|
||||||
|
})[publishState.value],
|
||||||
|
);
|
||||||
|
const discardConfirmation = createDiscardConfirmation((visible) => {
|
||||||
|
discardVisible.value = visible;
|
||||||
|
});
|
||||||
|
const requestDiscardConfirmation = discardConfirmation.request;
|
||||||
|
const confirmDiscard = discardConfirmation.confirm;
|
||||||
|
const cancelDiscard = discardConfirmation.cancel;
|
||||||
|
|
||||||
|
const resetForm = () => {
|
||||||
|
Object.assign(form, { feedContent: "", feedType: "text" });
|
||||||
|
mediaReceipts.value = [];
|
||||||
|
editingFeed.value = null;
|
||||||
|
formBaseline.value = "";
|
||||||
|
submitError.value = "";
|
||||||
|
uploadError.value = "";
|
||||||
|
};
|
||||||
|
const loadEditFeed = async (feedId) => {
|
||||||
|
try {
|
||||||
|
const detail = await familyFeedApi.getFeedDetail(genealogyId.value, feedId, {
|
||||||
|
requestController: feedDetailController,
|
||||||
|
});
|
||||||
|
if (!pageActive) return;
|
||||||
|
if (
|
||||||
|
!detail.canEdit ||
|
||||||
|
detail.type.toLowerCase() !== "text" ||
|
||||||
|
!["0", "1"].includes(detail.status) ||
|
||||||
|
!Number.isSafeInteger(detail.sortOrder)
|
||||||
|
) {
|
||||||
|
throw new Error("这条动态的信息不完整,暂未保存修改,以免覆盖原内容。");
|
||||||
|
}
|
||||||
|
resetForm();
|
||||||
|
Object.assign(form, { feedContent: detail.content, feedType: detail.type });
|
||||||
|
mediaReceipts.value = detail.mediaFiles.map((file) => ({
|
||||||
|
ossId: file.ossId,
|
||||||
|
fileName: file.fileName,
|
||||||
|
accessUrl: file.accessUrl,
|
||||||
|
}));
|
||||||
|
editingFeed.value = {
|
||||||
|
id: detail.id,
|
||||||
|
sortOrder: detail.sortOrder,
|
||||||
|
status: detail.status,
|
||||||
|
};
|
||||||
|
formBaseline.value = formSnapshot.value;
|
||||||
|
publishState.value = "form";
|
||||||
|
} catch (error) {
|
||||||
|
if (pageActive && !isRequestCancelled(error)) publishState.value = "unavailable";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
onLoad((query) => {
|
||||||
|
genealogyId.value = String(query?.genealogyId || "");
|
||||||
|
if (!hasValidContext.value) {
|
||||||
|
publishState.value = "invalid";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (query?.mode === "create") {
|
||||||
|
publishState.value = "form";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const feedId = String(query?.feedId || "");
|
||||||
|
if (query?.mode === "edit" && /^[1-9]\d*$/.test(feedId)) {
|
||||||
|
editFeedId.value = feedId;
|
||||||
|
loadEditFeed(feedId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
publishState.value = "invalid";
|
||||||
|
});
|
||||||
|
|
||||||
|
const uploadImage = async () => {
|
||||||
|
if (isUploading.value || isSubmitting.value) return;
|
||||||
|
isUploading.value = true;
|
||||||
|
uploadError.value = "";
|
||||||
|
try {
|
||||||
|
const receipt = await pickAndUploadImage({
|
||||||
|
requestController: feedMediaUploadController,
|
||||||
|
});
|
||||||
|
if (!pageActive) return;
|
||||||
|
mediaReceipts.value = [...mediaReceipts.value, receipt];
|
||||||
|
} catch (error) {
|
||||||
|
if (pageActive && !isImagePickCancelled(error) && !isRequestCancelled(error)) {
|
||||||
|
uploadError.value = getRequestErrorMessage(error, "图片上传失败,请稍后重试。");
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (pageActive) isUploading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getMediaPreviewUrl = (receipt) =>
|
||||||
|
String(receipt?.thumbnailUrl || receipt?.accessUrl || receipt?.url || "").trim();
|
||||||
|
|
||||||
|
const previewMedia = (receipt) => {
|
||||||
|
const current = getMediaPreviewUrl(receipt);
|
||||||
|
if (!current) return;
|
||||||
|
const urls = mediaReceipts.value.map(getMediaPreviewUrl).filter(Boolean);
|
||||||
|
uni.previewImage({ current, urls });
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeMedia = (index) => {
|
||||||
|
if (isUploading.value || isSubmitting.value || !Number.isInteger(index)) return;
|
||||||
|
mediaReceipts.value = mediaReceipts.value.filter((_, mediaIndex) => mediaIndex !== index);
|
||||||
|
uploadError.value = "";
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveFeed = async () => {
|
||||||
|
if (isSubmitting.value || isUploading.value || !hasValidContext.value) return;
|
||||||
|
if (!form.feedContent.trim()) {
|
||||||
|
submitError.value = "请填写动态内容";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const payload = {
|
||||||
|
feedContent: form.feedContent,
|
||||||
|
feedType: form.feedType,
|
||||||
|
mediaOssIds: mediaOssIds.value,
|
||||||
|
...(editingFeed.value
|
||||||
|
? {
|
||||||
|
sortOrder: editingFeed.value.sortOrder,
|
||||||
|
status: editingFeed.value.status,
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
};
|
||||||
|
const createAttempt = editingFeed.value ? null : feedCreateGuard.begin(payload);
|
||||||
|
if (!editingFeed.value && createAttempt === null) {
|
||||||
|
publishState.value = "error";
|
||||||
|
submitError.value =
|
||||||
|
"上次发布结果暂时无法确认,请先返回动态列表检查,避免重复发布。";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isSubmitting.value = true;
|
||||||
|
submitError.value = "";
|
||||||
|
try {
|
||||||
|
submittedEdit.value = Boolean(editingFeed.value);
|
||||||
|
if (editingFeed.value) {
|
||||||
|
await familyFeedApi.updateFeed(genealogyId.value, editingFeed.value.id, payload, {
|
||||||
|
requestController: feedSaveController,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await familyFeedApi.createFeed(genealogyId.value, payload, {
|
||||||
|
requestController: feedSaveController,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!pageActive) return;
|
||||||
|
resetForm();
|
||||||
|
publishState.value = "success";
|
||||||
|
} catch (error) {
|
||||||
|
if (!pageActive) return;
|
||||||
|
if (!editingFeed.value && feedCreateGuard.recordFailure(createAttempt, error)) {
|
||||||
|
publishState.value = "error";
|
||||||
|
submitError.value =
|
||||||
|
"发布结果暂时无法确认,请先返回动态列表检查,避免重复发布。";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isRequestCancelled(error)) return;
|
||||||
|
publishState.value = "error";
|
||||||
|
submitError.value = getRequestErrorMessage(error, "动态提交失败,请稍后重试。");
|
||||||
|
} finally {
|
||||||
|
if (pageActive) isSubmitting.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const requestBack = () =>
|
||||||
|
runBackGuard({
|
||||||
|
transientOpen: discardVisible.value,
|
||||||
|
dirty: isDirty.value,
|
||||||
|
submitting: isSubmitting.value || isUploading.value,
|
||||||
|
"close-transient": cancelDiscard,
|
||||||
|
"block-submitting": () => true,
|
||||||
|
"confirm-discard": requestDiscardConfirmation,
|
||||||
|
});
|
||||||
|
const returnToFamily = async () => {
|
||||||
|
const confirmed = isDirty.value ? await requestDiscardConfirmation() : true;
|
||||||
|
if (!confirmed) return false;
|
||||||
|
return returnTo("F12", { genealogyId: genealogyId.value });
|
||||||
|
};
|
||||||
|
const handleResultAction = () => {
|
||||||
|
if (publishState.value === "success") {
|
||||||
|
return submittedEdit.value
|
||||||
|
? returnTo("F03", {
|
||||||
|
genealogyId: genealogyId.value,
|
||||||
|
feedId: editFeedId.value,
|
||||||
|
})
|
||||||
|
: returnToFamily();
|
||||||
|
}
|
||||||
|
if (publishState.value === "error") {
|
||||||
|
publishState.value = "form";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (publishState.value === "unavailable") {
|
||||||
|
return returnTo("F03", {
|
||||||
|
genealogyId: genealogyId.value,
|
||||||
|
feedId: editFeedId.value,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return goBack();
|
||||||
|
};
|
||||||
|
onBackPress((event) => handleBackPress(event, requestBack));
|
||||||
|
onUnload(() => {
|
||||||
|
pageActive = false;
|
||||||
|
feedDetailController.abort();
|
||||||
|
feedMediaUploadController.abort();
|
||||||
|
feedSaveController.abort();
|
||||||
|
discardConfirmation.dispose();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
@import "../../styles/adaptive-frame-profiles.scss";
|
||||||
|
.publish-page {
|
||||||
|
display: flex;
|
||||||
|
min-height: 100vh;
|
||||||
|
flex-direction: column;
|
||||||
|
background: $paper;
|
||||||
|
}
|
||||||
|
.publish-page__header,
|
||||||
|
.publish-panel {
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
.publish-panel {
|
||||||
|
@include adaptive-family-content;
|
||||||
|
width: calc(100% - 32rpx);
|
||||||
|
margin: 18rpx auto calc(48rpx + env(safe-area-inset-bottom));
|
||||||
|
padding: 9%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
background-color: rgba($paper, 0.82);
|
||||||
|
}
|
||||||
|
.publish-form > text,
|
||||||
|
.publish-result > text {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.publish-form > text:first-child,
|
||||||
|
.publish-result > text:first-child {
|
||||||
|
color: $ink;
|
||||||
|
font-family: STKaiti, KaiTi, serif;
|
||||||
|
font-size: clamp(19px, 36rpx, 24px);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.publish-form > text:nth-child(2),
|
||||||
|
.publish-result > text:nth-child(2) {
|
||||||
|
margin-top: 12rpx;
|
||||||
|
color: $ink-muted;
|
||||||
|
font-size: clamp(14px, 23rpx, 17px);
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
.publish-field {
|
||||||
|
@include adaptive-family-field;
|
||||||
|
margin-top: 20rpx;
|
||||||
|
padding: 18rpx 22rpx;
|
||||||
|
}
|
||||||
|
.publish-field__label {
|
||||||
|
display: block;
|
||||||
|
color: $ink;
|
||||||
|
font-size: clamp(15px, 25rpx, 18px);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.required-mark {
|
||||||
|
display: inline-block;
|
||||||
|
margin-right: 4rpx;
|
||||||
|
color: $brand-red;
|
||||||
|
font-size: clamp(16px, 26rpx, 20px);
|
||||||
|
line-height: 1;
|
||||||
|
transform: translateY(-3rpx);
|
||||||
|
}
|
||||||
|
.publish-field input,
|
||||||
|
.publish-field textarea {
|
||||||
|
display: block;
|
||||||
|
box-sizing: border-box;
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 12rpx;
|
||||||
|
color: $ink;
|
||||||
|
font-size: clamp(15px, 24rpx, 18px);
|
||||||
|
}
|
||||||
|
.publish-field input {
|
||||||
|
min-height: 80rpx;
|
||||||
|
}
|
||||||
|
.publish-field__value--readonly {
|
||||||
|
display: flex;
|
||||||
|
min-height: 80rpx;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-top: 12rpx;
|
||||||
|
padding: 0 20rpx;
|
||||||
|
border: 1rpx solid rgba(128, 89, 49, 0.22);
|
||||||
|
border-radius: 10rpx;
|
||||||
|
background: rgba(255, 252, 245, 0.48);
|
||||||
|
}
|
||||||
|
.publish-field__value--readonly text:first-child {
|
||||||
|
color: $ink;
|
||||||
|
font-size: clamp(15px, 24rpx, 18px);
|
||||||
|
}
|
||||||
|
.publish-field__value--readonly text:last-child {
|
||||||
|
color: $ink-muted;
|
||||||
|
font-size: clamp(13px, 20rpx, 16px);
|
||||||
|
}
|
||||||
|
.publish-field--content textarea {
|
||||||
|
min-height: 200rpx;
|
||||||
|
line-height: 1.7;
|
||||||
|
}
|
||||||
|
.publish-field--media {
|
||||||
|
display: grid;
|
||||||
|
gap: 12rpx;
|
||||||
|
}
|
||||||
|
.publish-field__hint {
|
||||||
|
display: block;
|
||||||
|
margin-top: 6rpx;
|
||||||
|
color: $ink-muted;
|
||||||
|
font-size: clamp(13px, 20rpx, 16px);
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
.upload-button {
|
||||||
|
justify-self: start;
|
||||||
|
min-height: 80rpx;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0 20rpx;
|
||||||
|
border: 1rpx solid rgba(159, 23, 15, 0.42);
|
||||||
|
border-radius: 8rpx;
|
||||||
|
background: transparent;
|
||||||
|
color: $brand-red;
|
||||||
|
font-size: clamp(14px, 22rpx, 17px);
|
||||||
|
}
|
||||||
|
.upload-receipt {
|
||||||
|
color: $ink-muted;
|
||||||
|
font-size: clamp(13px, 21rpx, 16px);
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
.media-preview-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 14rpx;
|
||||||
|
}
|
||||||
|
.media-preview-card {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1rpx solid rgba(128, 89, 49, 0.24);
|
||||||
|
border-radius: 10rpx;
|
||||||
|
background: rgba(255, 253, 248, 0.62);
|
||||||
|
}
|
||||||
|
.media-preview-card__preview {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 176rpx;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 0;
|
||||||
|
background: rgba(128, 89, 49, 0.1);
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
.media-preview-card__preview::after,
|
||||||
|
.media-preview-card__remove::after {
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
.media-preview-card__preview:focus-visible,
|
||||||
|
.media-preview-card__remove:focus-visible {
|
||||||
|
outline: 2rpx solid $brand-red;
|
||||||
|
outline-offset: -2rpx;
|
||||||
|
}
|
||||||
|
.media-preview-card__preview:active:not([disabled]) {
|
||||||
|
background: rgba(128, 89, 49, 0.16);
|
||||||
|
}
|
||||||
|
.media-preview-card__preview[disabled],
|
||||||
|
.media-preview-card__remove[disabled] {
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
.media-preview-card__image,
|
||||||
|
.media-preview-card__fallback {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
.media-preview-card__fallback {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: $ink-muted;
|
||||||
|
font-size: clamp(12px, 19rpx, 15px);
|
||||||
|
}
|
||||||
|
.media-preview-card__meta {
|
||||||
|
display: flex;
|
||||||
|
min-height: 72rpx;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8rpx;
|
||||||
|
padding: 6rpx 6rpx 6rpx 14rpx;
|
||||||
|
}
|
||||||
|
.media-preview-card__meta > text {
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
color: $ink-muted;
|
||||||
|
font-size: clamp(12px, 19rpx, 15px);
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.media-preview-card__remove {
|
||||||
|
min-width: var(--app-touch-min);
|
||||||
|
min-height: var(--app-touch-min);
|
||||||
|
margin: 0;
|
||||||
|
padding: 0 10rpx;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: $brand-red;
|
||||||
|
font-size: clamp(13px, 20rpx, 16px);
|
||||||
|
line-height: 1;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.media-preview-card__remove:active:not([disabled]) {
|
||||||
|
background: rgba(159, 23, 15, 0.08);
|
||||||
|
}
|
||||||
|
.publish-placeholder {
|
||||||
|
color: #8e806e;
|
||||||
|
}
|
||||||
|
.publish-error {
|
||||||
|
margin-top: 12rpx;
|
||||||
|
color: $brand-red;
|
||||||
|
font-size: clamp(14px, 22rpx, 17px);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
.publish-form > .app-button {
|
||||||
|
margin-top: 24rpx;
|
||||||
|
}
|
||||||
|
.publish-result {
|
||||||
|
margin-top: 20%;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.publish-result > .app-button {
|
||||||
|
margin: 30rpx auto 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,340 @@
|
|||||||
|
<template>
|
||||||
|
<view class="family-page" :class="`feed-state--${feedState}`">
|
||||||
|
<ModulePageBackground module="family" />
|
||||||
|
<view class="family-page__header"
|
||||||
|
><PageHeader
|
||||||
|
title="家族动态"
|
||||||
|
custom-back
|
||||||
|
:action="hasValidContext ? '发布' : ''"
|
||||||
|
@back="requestBack"
|
||||||
|
@action="toPublish"
|
||||||
|
/></view>
|
||||||
|
<view class="feed-content">
|
||||||
|
<view class="feed-heading"
|
||||||
|
><text>家族圈</text><text>家宴、通知与共同记忆</text></view
|
||||||
|
>
|
||||||
|
<AppLoading
|
||||||
|
v-if="feedState === 'loading'"
|
||||||
|
text="正在读取家族动态"
|
||||||
|
description="请稍候,正在整理家族近况。"
|
||||||
|
/>
|
||||||
|
<view v-else-if="feedState === 'list'" class="feed-list">
|
||||||
|
<view
|
||||||
|
v-for="item in feeds"
|
||||||
|
:key="item.id"
|
||||||
|
class="feed-card"
|
||||||
|
@click="openFeed(item)"
|
||||||
|
>
|
||||||
|
<text class="feed-card__publisher">{{ item.publisher }}</text>
|
||||||
|
<text class="feed-card__content">{{ feedPreview(item.content) }}</text>
|
||||||
|
<FamilyFeedMedia :files="item.mediaFiles" />
|
||||||
|
<text class="feed-card__meta">{{ feedTypeLabel(item.type) }} · {{ formatMinuteTimestamp(item.time) }}</text>
|
||||||
|
<text v-if="item.recommendationReason" class="feed-card__reason">{{ item.recommendationReason }}</text>
|
||||||
|
</view>
|
||||||
|
<button
|
||||||
|
v-if="hasMoreFeeds || loadMoreState === 'loading' || loadMoreState === 'error'"
|
||||||
|
class="feed-more"
|
||||||
|
:disabled="loadMoreState === 'loading'"
|
||||||
|
@click="loadMoreFeeds"
|
||||||
|
>{{ loadMoreLabel }}</button>
|
||||||
|
</view>
|
||||||
|
<view v-else class="feed-state-card"
|
||||||
|
><view class="feed-state-card__copy"
|
||||||
|
><text>{{ stateCopy.title }}</text
|
||||||
|
><text>{{ stateCopy.copy }}</text></view
|
||||||
|
></view
|
||||||
|
>
|
||||||
|
<view class="feed-action" @click="handlePrimaryAction"
|
||||||
|
><text>{{ stateCopy.action }}</text></view
|
||||||
|
>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, ref } from "vue";
|
||||||
|
import { onBackPress, onLoad, onShow, onUnload } from "@dcloudio/uni-app";
|
||||||
|
import AppLoading from "@/components/AppLoading.vue";
|
||||||
|
import FamilyFeedMedia from "@/components/family/FeedMedia.vue";
|
||||||
|
import ModulePageBackground from "@/components/ModulePageBackground.vue";
|
||||||
|
import PageHeader from "@/components/PageHeader.vue";
|
||||||
|
import {
|
||||||
|
createRequestController,
|
||||||
|
isRequestCancelled
|
||||||
|
} from "@/services/api/request-controller.js";
|
||||||
|
import { familyFeedApi } from "@/services/api/family-feed-service.js";
|
||||||
|
import { formatMinuteTimestamp } from "@/utils/display-time.js";
|
||||||
|
import { goBack, handleBackPress, openPage } from "@/utils/navigation/gateway.js";
|
||||||
|
|
||||||
|
const genealogyId = ref("");
|
||||||
|
const hasValidContext = ref(false);
|
||||||
|
const feedState = ref("loading");
|
||||||
|
const feeds = ref([]);
|
||||||
|
const FEED_PAGE_SIZE = 20;
|
||||||
|
const totalFeedCount = ref(0);
|
||||||
|
const currentFeedPage = ref(1);
|
||||||
|
const loadMoreState = ref("idle");
|
||||||
|
const hasMoreFeeds = computed(
|
||||||
|
() => feedState.value === "list" && feeds.value.length < totalFeedCount.value,
|
||||||
|
);
|
||||||
|
const loadMoreLabel = computed(() => {
|
||||||
|
if (loadMoreState.value === "loading") return "正在加载";
|
||||||
|
if (loadMoreState.value === "error") return "加载失败,重新加载";
|
||||||
|
return "继续加载";
|
||||||
|
});
|
||||||
|
let active = true;
|
||||||
|
let skipInitialShowRefresh = true;
|
||||||
|
const feedListRequestController = createRequestController();
|
||||||
|
const recommendationRequestController = createRequestController();
|
||||||
|
const feedTypeLabel = (type) =>
|
||||||
|
String(type || "")
|
||||||
|
.trim()
|
||||||
|
.toLowerCase() === "text"
|
||||||
|
? "文字动态"
|
||||||
|
: "家族动态";
|
||||||
|
const feedPreview = (content) => {
|
||||||
|
const text = String(content || "").trim();
|
||||||
|
return text.length > 96 ? `${text.slice(0, 96).trimEnd()}…` : text;
|
||||||
|
};
|
||||||
|
const stateCopy = computed(() =>
|
||||||
|
!hasValidContext.value
|
||||||
|
? {
|
||||||
|
title: "请先选择有效家谱",
|
||||||
|
copy: "家族动态必须归属明确家谱。",
|
||||||
|
action: "返回我的家谱",
|
||||||
|
}
|
||||||
|
: feedState.value === "empty"
|
||||||
|
? {
|
||||||
|
title: "还没有家族动态",
|
||||||
|
copy: "发布第一条动态,记录家族此刻。",
|
||||||
|
action: "发布家族动态",
|
||||||
|
}
|
||||||
|
: feedState.value === "error"
|
||||||
|
? {
|
||||||
|
title: "动态暂时无法读取",
|
||||||
|
copy: "请稍后重新读取。",
|
||||||
|
action: "重新读取",
|
||||||
|
}
|
||||||
|
: { title: "", copy: "", action: "发布家族动态" },
|
||||||
|
);
|
||||||
|
const loadFeeds = async () => {
|
||||||
|
if (!hasValidContext.value) return;
|
||||||
|
feedState.value = "loading";
|
||||||
|
loadMoreState.value = "idle";
|
||||||
|
currentFeedPage.value = 1;
|
||||||
|
feedListRequestController.abort();
|
||||||
|
recommendationRequestController.abort();
|
||||||
|
try {
|
||||||
|
const [pageResult, recommendationResult] = await Promise.allSettled([
|
||||||
|
familyFeedApi.getFeedPage(
|
||||||
|
genealogyId.value,
|
||||||
|
{ pageNum: 1, pageSize: FEED_PAGE_SIZE },
|
||||||
|
{ requestController: feedListRequestController },
|
||||||
|
),
|
||||||
|
familyFeedApi.getFeedRecommendations(genealogyId.value, {
|
||||||
|
requestController: recommendationRequestController,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
if (pageResult.status === "rejected") throw pageResult.reason;
|
||||||
|
if (!active) return;
|
||||||
|
const recommendations =
|
||||||
|
recommendationResult.status === "fulfilled"
|
||||||
|
? recommendationResult.value
|
||||||
|
: [];
|
||||||
|
const reasons = new Map(
|
||||||
|
recommendations.map((item) => [String(item.id), item.recommendationReason]),
|
||||||
|
);
|
||||||
|
feeds.value = pageResult.value.rows.map((item) => ({
|
||||||
|
...item,
|
||||||
|
recommendationReason: reasons.get(String(item.id)) || "",
|
||||||
|
}));
|
||||||
|
totalFeedCount.value = pageResult.value.total;
|
||||||
|
loadMoreState.value =
|
||||||
|
feeds.value.length < totalFeedCount.value ? "idle" : "done";
|
||||||
|
feedState.value = feeds.value.length ? "list" : "empty";
|
||||||
|
} catch (error) {
|
||||||
|
if (!active || isRequestCancelled(error)) return;
|
||||||
|
feedState.value = "error";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const loadMoreFeeds = async () => {
|
||||||
|
if (!hasMoreFeeds.value || loadMoreState.value === "loading") return;
|
||||||
|
loadMoreState.value = "loading";
|
||||||
|
try {
|
||||||
|
const nextPage = currentFeedPage.value + 1;
|
||||||
|
const page = await familyFeedApi.getFeedPage(
|
||||||
|
genealogyId.value,
|
||||||
|
{ pageNum: nextPage, pageSize: FEED_PAGE_SIZE },
|
||||||
|
{ requestController: feedListRequestController },
|
||||||
|
);
|
||||||
|
if (!active) return;
|
||||||
|
const knownIds = new Set(feeds.value.map((item) => String(item.id)));
|
||||||
|
feeds.value = feeds.value.concat(
|
||||||
|
page.rows.filter((item) => !knownIds.has(String(item.id))),
|
||||||
|
);
|
||||||
|
currentFeedPage.value = nextPage;
|
||||||
|
totalFeedCount.value = page.total;
|
||||||
|
loadMoreState.value =
|
||||||
|
feeds.value.length < totalFeedCount.value ? "idle" : "done";
|
||||||
|
} catch (error) {
|
||||||
|
if (!active || isRequestCancelled(error)) return;
|
||||||
|
loadMoreState.value = "error";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
onLoad((query) => {
|
||||||
|
genealogyId.value = String(query?.genealogyId || "");
|
||||||
|
hasValidContext.value = /^[1-9]\d*$/.test(genealogyId.value);
|
||||||
|
if (hasValidContext.value) void loadFeeds();
|
||||||
|
else feedState.value = "error";
|
||||||
|
});
|
||||||
|
onShow(() => {
|
||||||
|
if (skipInitialShowRefresh) {
|
||||||
|
skipInitialShowRefresh = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (hasValidContext.value) void loadFeeds();
|
||||||
|
});
|
||||||
|
onUnload(() => {
|
||||||
|
active = false;
|
||||||
|
feedListRequestController.abort();
|
||||||
|
recommendationRequestController.abort();
|
||||||
|
});
|
||||||
|
const toPublish = () =>
|
||||||
|
hasValidContext.value
|
||||||
|
? openPage("F02", { genealogyId: genealogyId.value, mode: "create" }, "F12")
|
||||||
|
: goBack();
|
||||||
|
const openFeed = (item) =>
|
||||||
|
openPage("F03", { genealogyId: genealogyId.value, feedId: item.id }, "F12");
|
||||||
|
const handlePrimaryAction = () => {
|
||||||
|
if (!hasValidContext.value) return goBack();
|
||||||
|
return feedState.value === "error" ? loadFeeds() : toPublish();
|
||||||
|
};
|
||||||
|
const requestBack = () => goBack();
|
||||||
|
onBackPress((event) => handleBackPress(event, requestBack));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
@import "../../styles/adaptive-frame-profiles.scss";
|
||||||
|
.family-page {
|
||||||
|
display: flex;
|
||||||
|
min-height: 100vh;
|
||||||
|
flex-direction: column;
|
||||||
|
background: $paper;
|
||||||
|
}
|
||||||
|
.family-page__header,
|
||||||
|
.feed-content {
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
.feed-content {
|
||||||
|
flex: 1;
|
||||||
|
padding: 24rpx 24rpx calc(48rpx + env(safe-area-inset-bottom));
|
||||||
|
}
|
||||||
|
.feed-heading text {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.feed-heading text:first-child {
|
||||||
|
color: $ink;
|
||||||
|
font-family: STKaiti, KaiTi, serif;
|
||||||
|
font-size: clamp(17px, 31rpx, 22px);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.feed-heading text:last-child {
|
||||||
|
margin-top: 6rpx;
|
||||||
|
color: $ink-muted;
|
||||||
|
font-size: clamp(14px, 23rpx, 17px);
|
||||||
|
}
|
||||||
|
.feed-list {
|
||||||
|
margin-top: 22rpx;
|
||||||
|
}
|
||||||
|
.feed-card,
|
||||||
|
.feed-state-card {
|
||||||
|
@include adaptive-family-letter;
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin-top: 16rpx;
|
||||||
|
padding: 30rpx;
|
||||||
|
background-color: rgba($paper, 0.82);
|
||||||
|
}
|
||||||
|
.feed-card text,
|
||||||
|
.feed-state-card text {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.feed-card__publisher {
|
||||||
|
color: $brand-red;
|
||||||
|
font-size: clamp(14px, 22rpx, 17px);
|
||||||
|
}
|
||||||
|
.feed-card__content {
|
||||||
|
display: -webkit-box;
|
||||||
|
margin-top: 12rpx;
|
||||||
|
overflow: hidden;
|
||||||
|
color: $ink;
|
||||||
|
font-size: clamp(16px, 28rpx, 20px);
|
||||||
|
line-height: 1.55;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
-webkit-line-clamp: 3;
|
||||||
|
}
|
||||||
|
.feed-card__meta {
|
||||||
|
margin-top: 12rpx;
|
||||||
|
color: $ink-muted;
|
||||||
|
font-size: clamp(13px, 21rpx, 16px);
|
||||||
|
}
|
||||||
|
.feed-card__reason {
|
||||||
|
margin-top: 8rpx;
|
||||||
|
color: #9a6555;
|
||||||
|
font-size: clamp(12px, 20rpx, 15px);
|
||||||
|
}
|
||||||
|
.feed-more {
|
||||||
|
@include adaptive-scroll-button(secondary);
|
||||||
|
display: block;
|
||||||
|
width: 420rpx;
|
||||||
|
max-width: 100%;
|
||||||
|
min-height: 80rpx;
|
||||||
|
margin: 22rpx auto 0;
|
||||||
|
padding: 0 24rpx;
|
||||||
|
border: 0;
|
||||||
|
color: $ink;
|
||||||
|
font-size: clamp(14px, 22rpx, 17px);
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 80rpx;
|
||||||
|
}
|
||||||
|
.feed-more::after {
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
.feed-more[disabled] {
|
||||||
|
opacity: 0.62;
|
||||||
|
}
|
||||||
|
.feed-state-card {
|
||||||
|
min-height: 230rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.feed-state-card text:first-child {
|
||||||
|
color: $ink;
|
||||||
|
font-size: clamp(17px, 31rpx, 22px);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.feed-state-card text:last-child {
|
||||||
|
margin-top: 13rpx;
|
||||||
|
color: $ink-muted;
|
||||||
|
font-size: clamp(14px, 23rpx, 17px);
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
.feed-action {
|
||||||
|
@include adaptive-scroll-button(primary);
|
||||||
|
width: 514rpx;
|
||||||
|
max-width: 100%;
|
||||||
|
min-height: 80rpx;
|
||||||
|
margin: 19rpx auto;
|
||||||
|
}
|
||||||
|
.feed-action text {
|
||||||
|
display: flex;
|
||||||
|
min-height: 80rpx;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: #fff9ed;
|
||||||
|
font-size: clamp(15px, 24rpx, 18px);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
<template>
|
|
||||||
<view class="page-shell family-page">
|
|
||||||
<view class="family-top"><view><text class="top-kicker">家族共建</text><text class="page-name">家族记事</text></view><text class="publish" @click="publishFeed">发布动态</text></view>
|
|
||||||
<view v-if="loadError" class="error-copy">{{ loadError }}</view>
|
|
||||||
<view class="content-rail"><view v-for="item in sections" :key="item.type" class="rail-item" @click="openSection(item.type)"><text>{{ item.name }}</text><text>{{ item.detail }}</text></view></view>
|
|
||||||
<view class="feed-heading"><text class="section-title">家族近况</text><text class="feed-filter">全部动态</text></view>
|
|
||||||
<view class="feed-list"><view v-for="feed in feeds" :key="feed.id" class="feed-card paper-card"><view class="feed-meta"><text>{{ feed.type || feed.feedType || '动态' }}</text><text>{{ feed.date || feed.createTime || '' }}</text></view><text class="feed-title">{{ feed.title || '家族动态' }}</text><text class="feed-content">{{ feed.content || feed.feedContent }}</text><text class="feed-author">发布人:{{ feed.author || feed.creatorName || '族人' }}</text></view><view v-if="!feeds.length && !loadError" class="empty-copy">暂未发布家族动态</view></view>
|
|
||||||
<AppTabbar active="family" />
|
|
||||||
</view>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { onMounted, ref } from 'vue'
|
|
||||||
import AppTabbar from '@/components/AppTabbar.vue'
|
|
||||||
import { appApi } from '@/utils/api.js'
|
|
||||||
import { genealogyContext } from '@/utils/genealogy-context.js'
|
|
||||||
|
|
||||||
const feeds = ref([])
|
|
||||||
const loadError = ref('')
|
|
||||||
const genealogyId = ref('')
|
|
||||||
const sections = [
|
|
||||||
{ type: 'article', name: '谱文', detail: '族史与家训' },
|
|
||||||
{ type: 'album', name: '相册', detail: '珍存旧影' },
|
|
||||||
{ type: 'ceremony', name: '祭祀', detail: '慎终追远' },
|
|
||||||
{ type: 'record', name: '族务', detail: '共同记事' }
|
|
||||||
]
|
|
||||||
|
|
||||||
onMounted(async () => {
|
|
||||||
genealogyId.value = genealogyContext.getCurrentGenealogyId()
|
|
||||||
if (!genealogyId.value) { loadError.value = '请先从“我的家谱”选择一个家谱。'; return }
|
|
||||||
try { feeds.value = await appApi.getFeeds(genealogyId.value) } catch (error) { loadError.value = error.message || '家族动态加载失败。' }
|
|
||||||
})
|
|
||||||
const openSection = (type) => uni.navigateTo({ url: `/pages/content/list?type=${type}&genealogyId=${genealogyId.value}` })
|
|
||||||
const publishFeed = () => {
|
|
||||||
if (!genealogyId.value) { uni.showToast({ title: '请先选择家谱', icon: 'none' }); return }
|
|
||||||
uni.navigateTo({ url: `/pages/content/editor?type=feed&genealogyId=${genealogyId.value}` })
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped lang="scss">
|
|
||||||
.family-page { padding: 42rpx 28rpx 178rpx; }
|
|
||||||
.family-top { display: flex; align-items: flex-start; justify-content: space-between; }
|
|
||||||
.top-kicker { display: block; color: $brand-red; font-size: 22rpx; letter-spacing: 5rpx; }
|
|
||||||
.page-name { display: block; margin-top: 10rpx; color: $ink; font-family: serif; font-size: 52rpx; font-weight: 700; letter-spacing: 5rpx; }
|
|
||||||
.publish { margin-top: 22rpx; color: $brand-red; font-size: 26rpx; }
|
|
||||||
.error-copy { margin-top: 24rpx; color: $brand-red; font-size: 24rpx; }
|
|
||||||
.content-rail { display: flex; gap: 12rpx; margin-top: 34rpx; overflow-x: auto; }
|
|
||||||
.rail-item { min-width: 150rpx; padding: 18rpx; border: 1rpx solid $gold-soft; background: #fffaf0; }
|
|
||||||
.rail-item text { display: block; color: $ink; font-size: 28rpx; font-weight: 700; }
|
|
||||||
.rail-item text + text { margin-top: 8rpx; color: $ink-muted; font-size: 20rpx; font-weight: 400; }
|
|
||||||
.feed-heading { display: flex; justify-content: space-between; align-items: center; margin-top: 38rpx; }
|
|
||||||
.feed-filter { color: $brand-red; font-size: 24rpx; }
|
|
||||||
.feed-list { display: flex; flex-direction: column; gap: 18rpx; margin-top: 20rpx; }
|
|
||||||
.feed-card { padding: 28rpx; }
|
|
||||||
.feed-meta { display: flex; justify-content: space-between; color: $gold; font-size: 21rpx; }
|
|
||||||
.feed-title { display: block; margin-top: 16rpx; color: $ink; font-size: 32rpx; font-weight: 700; }
|
|
||||||
.feed-content { display: block; margin-top: 12rpx; color: $ink-muted; font-size: 25rpx; line-height: 1.65; }
|
|
||||||
.feed-author { display: block; margin-top: 16rpx; color: #9a8c78; font-size: 21rpx; }
|
|
||||||
.empty-copy { padding: 80rpx 0; color: $ink-muted; font-size: 26rpx; text-align: center; }
|
|
||||||
</style>
|
|
||||||
@@ -0,0 +1,564 @@
|
|||||||
|
<template>
|
||||||
|
<view class="platform-video-page">
|
||||||
|
<ModulePageBackground module="family" />
|
||||||
|
<view class="page-header">
|
||||||
|
<PageHeader title="宣传视频" custom-back @back="requestBack" />
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="page-content">
|
||||||
|
<view v-if="pageState === 'loading'" class="state-card">
|
||||||
|
<AppLoading text="正在加载平台视频" />
|
||||||
|
</view>
|
||||||
|
<view v-else-if="pageState === 'error'" class="state-card">
|
||||||
|
<text>{{ pageError }}</text>
|
||||||
|
<AppButton block label="重新加载" @click="loadPlatformVideos" />
|
||||||
|
</view>
|
||||||
|
<view v-else-if="!videos.length" class="state-card">
|
||||||
|
<text>暂时没有可观看的平台视频</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view v-else class="video-list">
|
||||||
|
<AppButton
|
||||||
|
block
|
||||||
|
type="secondary"
|
||||||
|
label="上下滑动观看"
|
||||||
|
@click="openVerticalViewer(videos[0])"
|
||||||
|
/>
|
||||||
|
<view v-for="video in videos" :key="video.id" class="video-card">
|
||||||
|
<view
|
||||||
|
v-if="video.coverFile?.accessUrl"
|
||||||
|
class="video-card__cover-button"
|
||||||
|
role="button"
|
||||||
|
:aria-label="`播放${video.title}`"
|
||||||
|
hover-class="action-hover"
|
||||||
|
@click="openVerticalViewer(video)"
|
||||||
|
>
|
||||||
|
<image
|
||||||
|
:src="video.coverFile.accessUrl"
|
||||||
|
mode="aspectFill"
|
||||||
|
class="video-card__cover"
|
||||||
|
/>
|
||||||
|
<view class="video-card__play" aria-hidden="true"></view>
|
||||||
|
</view>
|
||||||
|
<view
|
||||||
|
v-else
|
||||||
|
class="video-card__cover-button video-card__cover-placeholder"
|
||||||
|
role="button"
|
||||||
|
:aria-label="`播放${video.title}`"
|
||||||
|
hover-class="action-hover"
|
||||||
|
@click="openVerticalViewer(video)"
|
||||||
|
>
|
||||||
|
<image
|
||||||
|
class="video-card__placeholder-seal"
|
||||||
|
src="/static/assets/foundation/transparent/brand-seal.png"
|
||||||
|
mode="aspectFit"
|
||||||
|
/>
|
||||||
|
<view class="video-card__play" aria-hidden="true"></view>
|
||||||
|
</view>
|
||||||
|
<text class="video-card__title">{{ video.title }}</text>
|
||||||
|
<text v-if="video.description" class="video-card__copy">{{ video.description }}</text>
|
||||||
|
<text v-if="video.startAt" class="video-card__meta">发布时间:{{ video.startAt }}</text>
|
||||||
|
<view class="video-card__actions">
|
||||||
|
<AppButton
|
||||||
|
compact
|
||||||
|
type="secondary"
|
||||||
|
:disabled="Boolean(actionKey)"
|
||||||
|
label="沉浸观看"
|
||||||
|
@click="openVerticalViewer(video)"
|
||||||
|
/>
|
||||||
|
<AppButton
|
||||||
|
compact
|
||||||
|
type="secondary"
|
||||||
|
:disabled="Boolean(actionKey)"
|
||||||
|
:label="video.likedByCurrentUser ? `已赞 ${video.likeCount}` : `点赞 ${video.likeCount}`"
|
||||||
|
@click="togglePlatformVideoLike(video)"
|
||||||
|
/>
|
||||||
|
<AppButton
|
||||||
|
compact
|
||||||
|
type="secondary"
|
||||||
|
:disabled="Boolean(actionKey)"
|
||||||
|
:label="`评论 ${video.commentCount}`"
|
||||||
|
@click="openPlatformVideoComments(video)"
|
||||||
|
/>
|
||||||
|
</view>
|
||||||
|
<text
|
||||||
|
v-if="actionErrorVideoId === video.id"
|
||||||
|
class="action-error"
|
||||||
|
role="alert"
|
||||||
|
>{{ actionError }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<AppDialog
|
||||||
|
:visible="Boolean(commentTarget)"
|
||||||
|
eyebrow="视频评论"
|
||||||
|
:title="commentTarget?.title || '平台视频'"
|
||||||
|
:confirm-text="actionKey === 'send-comment' ? '正在发送' : '发表评论'"
|
||||||
|
cancel-text="关闭"
|
||||||
|
show-cancel
|
||||||
|
:close-on-mask="false"
|
||||||
|
@confirm="sendPlatformVideoComment"
|
||||||
|
@cancel="closePlatformVideoComments"
|
||||||
|
>
|
||||||
|
<view v-if="comments.length" class="comment-list">
|
||||||
|
<view v-for="comment in comments" :key="comment.id" class="comment-row">
|
||||||
|
<text>{{ comment.author }}:{{ comment.content }}</text>
|
||||||
|
<text v-if="comment.time" class="comment-row__time">{{ comment.time }}</text>
|
||||||
|
<button
|
||||||
|
v-if="comment.canDelete"
|
||||||
|
class="comment-row__delete"
|
||||||
|
:disabled="Boolean(actionKey)"
|
||||||
|
@click="requestDeletePlatformVideoComment(comment)"
|
||||||
|
>删除</button>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<text v-else class="comments-empty">还没有评论,可以先说说你的看法。</text>
|
||||||
|
<text v-if="commentError" class="action-error" role="alert">{{ commentError }}</text>
|
||||||
|
<textarea
|
||||||
|
v-model="commentText"
|
||||||
|
maxlength="1000"
|
||||||
|
placeholder="说说你的看法"
|
||||||
|
class="comment-input"
|
||||||
|
@input="commentError = ''"
|
||||||
|
/>
|
||||||
|
</AppDialog>
|
||||||
|
<VerticalVideoViewer
|
||||||
|
:visible="verticalViewerVisible"
|
||||||
|
:videos="videos"
|
||||||
|
:initial-video-id="verticalViewerInitialId"
|
||||||
|
title="宣传视频"
|
||||||
|
:action-busy="Boolean(actionKey)"
|
||||||
|
@close="closeVerticalViewer"
|
||||||
|
@like="togglePlatformVideoLike"
|
||||||
|
@comments="openVerticalViewerComments"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<AppDialog
|
||||||
|
:visible="Boolean(commentDeleteTarget)"
|
||||||
|
eyebrow="评论管理"
|
||||||
|
title="删除这条评论?"
|
||||||
|
message="删除后将按服务端规则保留占位或移除内容。"
|
||||||
|
:confirm-text="actionKey === 'delete-comment' ? '正在删除' : '确认删除'"
|
||||||
|
cancel-text="保留评论"
|
||||||
|
show-cancel
|
||||||
|
:close-on-mask="false"
|
||||||
|
@confirm="deletePlatformVideoComment"
|
||||||
|
@cancel="closePlatformVideoCommentDelete"
|
||||||
|
/>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref } from "vue";
|
||||||
|
import { onBackPress, onLoad, onUnload } from "@dcloudio/uni-app";
|
||||||
|
import AppButton from "@/components/AppButton.vue";
|
||||||
|
import AppDialog from "@/components/AppDialog.vue";
|
||||||
|
import AppLoading from "@/components/AppLoading.vue";
|
||||||
|
import ModulePageBackground from "@/components/ModulePageBackground.vue";
|
||||||
|
import PageHeader from "@/components/PageHeader.vue";
|
||||||
|
import VerticalVideoViewer from "@/components/family/VerticalVideoViewer.vue";
|
||||||
|
import { PLATFORM_VIDEO_PLACEMENT } from "@/services/api/family-media-contract.js";
|
||||||
|
import { genealogyCapabilityApi } from "@/services/api/genealogy-capability-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";
|
||||||
|
import { goBack, handleBackPress } from "@/utils/navigation/gateway.js";
|
||||||
|
|
||||||
|
const placement = ref(PLATFORM_VIDEO_PLACEMENT.VIDEO_CENTER);
|
||||||
|
const requestedVideoId = ref("");
|
||||||
|
const videos = ref([]);
|
||||||
|
const verticalViewerVisible = ref(false);
|
||||||
|
const verticalViewerInitialId = ref("");
|
||||||
|
const pageState = ref("loading");
|
||||||
|
const pageError = ref("");
|
||||||
|
const actionKey = ref("");
|
||||||
|
const actionErrorVideoId = ref("");
|
||||||
|
const actionError = ref("");
|
||||||
|
const commentTarget = ref(null);
|
||||||
|
const comments = ref([]);
|
||||||
|
const commentText = ref("");
|
||||||
|
const commentError = ref("");
|
||||||
|
const commentDeleteTarget = ref(null);
|
||||||
|
const platformVideoListController = createRequestController();
|
||||||
|
const platformVideoLikeController = createRequestController();
|
||||||
|
const platformVideoCommentListController = createRequestController();
|
||||||
|
const platformVideoCommentWriteController = createRequestController();
|
||||||
|
const platformVideoCommentDeleteController = createRequestController();
|
||||||
|
const platformVideoCommentGuard = createNonIdempotentWriteGuard();
|
||||||
|
let pageActive = true;
|
||||||
|
|
||||||
|
const openVerticalViewer = (video) => {
|
||||||
|
if (!video || actionKey.value) return;
|
||||||
|
verticalViewerInitialId.value = String(video.id);
|
||||||
|
verticalViewerVisible.value = true;
|
||||||
|
};
|
||||||
|
const closeVerticalViewer = () => {
|
||||||
|
verticalViewerVisible.value = false;
|
||||||
|
};
|
||||||
|
const openVerticalViewerComments = (video) => {
|
||||||
|
closeVerticalViewer();
|
||||||
|
return openPlatformVideoComments(video);
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadPlatformVideos = async () => {
|
||||||
|
platformVideoListController.abort();
|
||||||
|
pageState.value = "loading";
|
||||||
|
pageError.value = "";
|
||||||
|
try {
|
||||||
|
const rows = await genealogyCapabilityApi.getPlatformVideos(
|
||||||
|
placement.value,
|
||||||
|
{ requestController: platformVideoListController },
|
||||||
|
);
|
||||||
|
if (!pageActive) return;
|
||||||
|
videos.value = rows;
|
||||||
|
pageState.value = "ready";
|
||||||
|
if (requestedVideoId.value) {
|
||||||
|
const requestedVideo = rows.find(
|
||||||
|
(video) => String(video.id) === requestedVideoId.value,
|
||||||
|
);
|
||||||
|
requestedVideoId.value = "";
|
||||||
|
if (requestedVideo) openVerticalViewer(requestedVideo);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (!pageActive || isRequestCancelled(error)) return;
|
||||||
|
pageError.value = getRequestErrorMessage(error, "平台视频暂时无法读取。");
|
||||||
|
pageState.value = "error";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const togglePlatformVideoLike = async (video) => {
|
||||||
|
if (actionKey.value) return;
|
||||||
|
const liked = !video.likedByCurrentUser;
|
||||||
|
actionKey.value = `like-${video.id}`;
|
||||||
|
actionErrorVideoId.value = "";
|
||||||
|
actionError.value = "";
|
||||||
|
try {
|
||||||
|
await genealogyCapabilityApi.setPlatformVideoLike(
|
||||||
|
video.id,
|
||||||
|
liked,
|
||||||
|
{ requestController: platformVideoLikeController },
|
||||||
|
);
|
||||||
|
if (!pageActive) return;
|
||||||
|
video.likedByCurrentUser = liked;
|
||||||
|
video.likeCount = Math.max(0, video.likeCount + (liked ? 1 : -1));
|
||||||
|
} catch (error) {
|
||||||
|
if (!pageActive || isRequestCancelled(error)) return;
|
||||||
|
actionErrorVideoId.value = video.id;
|
||||||
|
actionError.value = getRequestErrorMessage(error, "点赞失败,请稍后重试。");
|
||||||
|
} finally {
|
||||||
|
if (pageActive) actionKey.value = "";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const openPlatformVideoComments = async (video) => {
|
||||||
|
if (actionKey.value) return;
|
||||||
|
platformVideoCommentListController.abort();
|
||||||
|
actionKey.value = `comments-${video.id}`;
|
||||||
|
actionErrorVideoId.value = "";
|
||||||
|
actionError.value = "";
|
||||||
|
try {
|
||||||
|
const rows = await genealogyCapabilityApi.getPlatformVideoComments(
|
||||||
|
video.id,
|
||||||
|
{ requestController: platformVideoCommentListController },
|
||||||
|
);
|
||||||
|
if (!pageActive) return;
|
||||||
|
comments.value = rows;
|
||||||
|
commentTarget.value = video;
|
||||||
|
commentText.value = "";
|
||||||
|
commentError.value = "";
|
||||||
|
} catch (error) {
|
||||||
|
if (!pageActive || isRequestCancelled(error)) return;
|
||||||
|
actionErrorVideoId.value = video.id;
|
||||||
|
actionError.value = getRequestErrorMessage(error, "评论暂时无法读取。");
|
||||||
|
} finally {
|
||||||
|
if (pageActive) actionKey.value = "";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const closePlatformVideoComments = () => {
|
||||||
|
if (["send-comment", "delete-comment"].includes(actionKey.value)) return;
|
||||||
|
commentTarget.value = null;
|
||||||
|
commentDeleteTarget.value = null;
|
||||||
|
comments.value = [];
|
||||||
|
commentText.value = "";
|
||||||
|
commentError.value = "";
|
||||||
|
};
|
||||||
|
|
||||||
|
const sendPlatformVideoComment = async () => {
|
||||||
|
const commentContent = commentText.value.trim();
|
||||||
|
if (!commentContent || !commentTarget.value || actionKey.value) return;
|
||||||
|
const commentPayload = {
|
||||||
|
videoId: commentTarget.value.id,
|
||||||
|
commentContent,
|
||||||
|
};
|
||||||
|
const commentAttempt = platformVideoCommentGuard.begin(commentPayload);
|
||||||
|
if (commentAttempt === null) {
|
||||||
|
commentError.value = "上次评论发送结果待确认,请重新打开评论列表检查,避免重复发布。";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
actionKey.value = "send-comment";
|
||||||
|
commentError.value = "";
|
||||||
|
try {
|
||||||
|
const createdComment = await genealogyCapabilityApi.createPlatformVideoComment(
|
||||||
|
commentPayload.videoId,
|
||||||
|
commentPayload.commentContent,
|
||||||
|
{ requestController: platformVideoCommentWriteController },
|
||||||
|
);
|
||||||
|
if (!pageActive || !commentTarget.value) return;
|
||||||
|
comments.value.push(createdComment);
|
||||||
|
commentTarget.value.commentCount += 1;
|
||||||
|
commentText.value = "";
|
||||||
|
} catch (error) {
|
||||||
|
const isOutcomeUnknown = platformVideoCommentGuard.recordFailure(commentAttempt, error);
|
||||||
|
if (!pageActive || !commentTarget.value) return;
|
||||||
|
commentError.value = isOutcomeUnknown
|
||||||
|
? "评论发送结果待确认,请重新打开评论列表检查,避免重复发布。"
|
||||||
|
: getRequestErrorMessage(error, "评论发送失败,请稍后重试。");
|
||||||
|
} finally {
|
||||||
|
if (pageActive) actionKey.value = "";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const requestDeletePlatformVideoComment = (comment) => {
|
||||||
|
if (!comment?.canDelete || actionKey.value) return;
|
||||||
|
commentError.value = "";
|
||||||
|
commentDeleteTarget.value = comment;
|
||||||
|
};
|
||||||
|
|
||||||
|
const closePlatformVideoCommentDelete = () => {
|
||||||
|
if (actionKey.value !== "delete-comment") commentDeleteTarget.value = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const deletePlatformVideoComment = async () => {
|
||||||
|
if (!commentTarget.value || !commentDeleteTarget.value?.canDelete || actionKey.value) return;
|
||||||
|
const target = commentDeleteTarget.value;
|
||||||
|
actionKey.value = "delete-comment";
|
||||||
|
commentError.value = "";
|
||||||
|
try {
|
||||||
|
await genealogyCapabilityApi.deletePlatformVideoComment(
|
||||||
|
commentTarget.value.id,
|
||||||
|
target.id,
|
||||||
|
{ requestController: platformVideoCommentDeleteController },
|
||||||
|
);
|
||||||
|
if (!pageActive || !commentTarget.value) return;
|
||||||
|
comments.value = await genealogyCapabilityApi.getPlatformVideoComments(
|
||||||
|
commentTarget.value.id,
|
||||||
|
{ requestController: platformVideoCommentListController },
|
||||||
|
);
|
||||||
|
commentTarget.value.commentCount = comments.value.length;
|
||||||
|
commentDeleteTarget.value = null;
|
||||||
|
} catch (error) {
|
||||||
|
if (!pageActive || isRequestCancelled(error)) return;
|
||||||
|
commentError.value = getRequestErrorMessage(error, "评论删除失败,请稍后重试。");
|
||||||
|
commentDeleteTarget.value = null;
|
||||||
|
} finally {
|
||||||
|
if (pageActive) actionKey.value = "";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const requestBack = () => {
|
||||||
|
if (verticalViewerVisible.value) {
|
||||||
|
closeVerticalViewer();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (commentDeleteTarget.value) {
|
||||||
|
closePlatformVideoCommentDelete();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (commentTarget.value) {
|
||||||
|
closePlatformVideoComments();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return goBack();
|
||||||
|
};
|
||||||
|
|
||||||
|
onLoad((query) => {
|
||||||
|
if (Object.values(PLATFORM_VIDEO_PLACEMENT).includes(query?.placement)) {
|
||||||
|
placement.value = query.placement;
|
||||||
|
}
|
||||||
|
requestedVideoId.value = String(query?.videoId || "");
|
||||||
|
void loadPlatformVideos();
|
||||||
|
});
|
||||||
|
onBackPress((event) => handleBackPress(event, requestBack));
|
||||||
|
onUnload(() => {
|
||||||
|
pageActive = false;
|
||||||
|
platformVideoListController.abort();
|
||||||
|
platformVideoLikeController.abort();
|
||||||
|
platformVideoCommentListController.abort();
|
||||||
|
platformVideoCommentWriteController.abort();
|
||||||
|
platformVideoCommentDeleteController.abort();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.platform-video-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
color: $ink;
|
||||||
|
}
|
||||||
|
.page-header,
|
||||||
|
.page-content {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
.page-content {
|
||||||
|
padding: 24rpx 28rpx calc(72rpx + env(safe-area-inset-bottom));
|
||||||
|
}
|
||||||
|
.state-card,
|
||||||
|
.video-card {
|
||||||
|
border: 1rpx solid rgba($gold, .38);
|
||||||
|
border-radius: 16rpx;
|
||||||
|
background: rgba(255, 252, 245, .92);
|
||||||
|
}
|
||||||
|
.state-card {
|
||||||
|
padding: 48rpx 28rpx;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.state-card text {
|
||||||
|
display: block;
|
||||||
|
color: $ink-muted;
|
||||||
|
}
|
||||||
|
.state-card .app-button {
|
||||||
|
margin-top: 20rpx;
|
||||||
|
}
|
||||||
|
.video-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 20rpx;
|
||||||
|
}
|
||||||
|
.video-card {
|
||||||
|
padding: 28rpx;
|
||||||
|
}
|
||||||
|
.video-card__cover-button {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
height: 360rpx;
|
||||||
|
border-radius: 10rpx;
|
||||||
|
background: #1f1b17;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.video-card__cover-placeholder {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: linear-gradient(145deg, #f4e5cd, #d8b77c);
|
||||||
|
}
|
||||||
|
.video-card__placeholder-seal {
|
||||||
|
width: 132rpx;
|
||||||
|
height: 152rpx;
|
||||||
|
opacity: .72;
|
||||||
|
}
|
||||||
|
.video-card__cover {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
.video-card__play {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
display: flex;
|
||||||
|
width: 76rpx;
|
||||||
|
height: 76rpx;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border: 2rpx solid rgba(255, 255, 255, .9);
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(31, 27, 23, .64);
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
}
|
||||||
|
.video-card__play::after {
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
margin-left: 6rpx;
|
||||||
|
border-top: 13rpx solid transparent;
|
||||||
|
border-bottom: 13rpx solid transparent;
|
||||||
|
border-left: 20rpx solid #fff;
|
||||||
|
content: "";
|
||||||
|
}
|
||||||
|
.video-card__title,
|
||||||
|
.video-card__copy,
|
||||||
|
.video-card__meta,
|
||||||
|
.comments-empty,
|
||||||
|
.action-error,
|
||||||
|
.comment-row text {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.video-card__title {
|
||||||
|
margin-top: 16rpx;
|
||||||
|
font-size: clamp(18px, 30rpx, 22px);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.video-card__copy,
|
||||||
|
.video-card__meta {
|
||||||
|
margin-top: 10rpx;
|
||||||
|
color: $ink-muted;
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
.video-card__meta {
|
||||||
|
font-size: clamp(12px, 21rpx, 15px);
|
||||||
|
}
|
||||||
|
.video-card__actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 16rpx;
|
||||||
|
margin-top: 20rpx;
|
||||||
|
}
|
||||||
|
.comment-list {
|
||||||
|
width: 100%;
|
||||||
|
max-height: 440rpx;
|
||||||
|
overflow-y: auto;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.comment-row {
|
||||||
|
position: relative;
|
||||||
|
padding: 14rpx 96rpx 14rpx 0;
|
||||||
|
border-bottom: 1rpx solid rgba($gold, .2);
|
||||||
|
color: $ink;
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
.comment-row__time {
|
||||||
|
margin-top: 4rpx;
|
||||||
|
color: $ink-muted;
|
||||||
|
font-size: clamp(12px, 20rpx, 14px);
|
||||||
|
}
|
||||||
|
.comment-row__delete {
|
||||||
|
position: absolute;
|
||||||
|
top: 8rpx;
|
||||||
|
right: 0;
|
||||||
|
min-height: var(--app-touch-min);
|
||||||
|
margin: 0;
|
||||||
|
padding: 0 10rpx;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: $brand-red;
|
||||||
|
font-size: clamp(12px, 20rpx, 14px);
|
||||||
|
line-height: var(--app-touch-min);
|
||||||
|
}
|
||||||
|
.comment-row__delete::after {
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
.comments-empty {
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 18rpx;
|
||||||
|
color: $ink-muted;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.action-error {
|
||||||
|
margin-top: 14rpx;
|
||||||
|
color: $brand-red-dark;
|
||||||
|
font-size: clamp(13px, 22rpx, 16px);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
.comment-input {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 120rpx;
|
||||||
|
margin-top: 18rpx;
|
||||||
|
padding: 16rpx;
|
||||||
|
border: 1rpx solid rgba($gold, .38);
|
||||||
|
border-radius: 10rpx;
|
||||||
|
box-sizing: border-box;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<template>
|
||||||
|
<SiteHome subpage-mode />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import SiteHome from "@/pages/family/site-home.vue";
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,354 @@
|
|||||||
|
<template>
|
||||||
|
<view class="site-home-page">
|
||||||
|
<ModulePageBackground module="family" />
|
||||||
|
<PageHeader :root="!subpageMode" :custom-back="subpageMode" :title="headerTitle" />
|
||||||
|
|
||||||
|
<scroll-view class="site-home-scroll" scroll-y>
|
||||||
|
<view class="site-home-content" :class="{ 'site-home-content--root': !subpageMode }">
|
||||||
|
<view class="site-home-heading">
|
||||||
|
<text>{{ pageTitle }}</text>
|
||||||
|
<text>{{ pageDescription }}</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<AppLoading
|
||||||
|
v-if="articleState === 'loading'"
|
||||||
|
text="正在整理资讯"
|
||||||
|
description="请稍候,正在读取最新内容。"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<view v-else-if="articleState === 'list'" class="site-article-list">
|
||||||
|
<button
|
||||||
|
v-for="article in articles"
|
||||||
|
:key="article.id"
|
||||||
|
class="site-article-card"
|
||||||
|
:aria-label="`${article.title},查看文章`"
|
||||||
|
@click="openArticle(article)"
|
||||||
|
>
|
||||||
|
<view class="site-article-card__meta">
|
||||||
|
<text>{{ article.typeLabel }}</text>
|
||||||
|
<text v-if="article.publishTime">{{ formatArticleDate(article.publishTime) }}</text>
|
||||||
|
</view>
|
||||||
|
<text class="site-article-card__title">{{ article.title }}</text>
|
||||||
|
<text v-if="article.summary" class="site-article-card__summary">
|
||||||
|
{{ article.summary }}
|
||||||
|
</text>
|
||||||
|
<text class="site-article-card__action">
|
||||||
|
{{ article.externalUrl ? "前往阅读" : "阅读全文" }} ›
|
||||||
|
</text>
|
||||||
|
</button>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view v-else class="site-home-state">
|
||||||
|
<text>{{ articleState === "empty" ? "暂时没有资讯" : "资讯暂时无法读取" }}</text>
|
||||||
|
<text>{{ articleState === "empty" ? "新内容发布后会在这里展示。" : articleError }}</text>
|
||||||
|
<button v-if="articleState === 'error'" @click="loadArticles">重新加载</button>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<text v-if="openError" class="site-home-error" role="alert">{{ openError }}</text>
|
||||||
|
<AppPromotionStrip placement="home_bottom" title="更多内容" />
|
||||||
|
</view>
|
||||||
|
</scroll-view>
|
||||||
|
|
||||||
|
<AppDialog
|
||||||
|
:visible="Boolean(selectedArticle)"
|
||||||
|
:eyebrow="selectedArticle?.typeLabel || '传承资讯'"
|
||||||
|
:title="selectedArticle?.title || '文章详情'"
|
||||||
|
:message="selectedArticleContent"
|
||||||
|
confirm-text="关闭"
|
||||||
|
@confirm="closeArticle"
|
||||||
|
@close="closeArticle"
|
||||||
|
/>
|
||||||
|
<AppTabbar v-if="!subpageMode" active="family" />
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, ref } from "vue";
|
||||||
|
import { onLoad, onUnload } from "@dcloudio/uni-app";
|
||||||
|
import AppDialog from "@/components/AppDialog.vue";
|
||||||
|
import AppLoading from "@/components/AppLoading.vue";
|
||||||
|
import AppPromotionStrip from "@/components/AppPromotionStrip.vue";
|
||||||
|
import AppTabbar from "@/components/AppTabbar.vue";
|
||||||
|
import ModulePageBackground from "@/components/ModulePageBackground.vue";
|
||||||
|
import PageHeader from "@/components/PageHeader.vue";
|
||||||
|
import {
|
||||||
|
createRequestController,
|
||||||
|
isRequestCancelled,
|
||||||
|
} from "@/services/api/request-controller.js";
|
||||||
|
import { getRequestErrorMessage } from "@/services/api/request-error-message.js";
|
||||||
|
import { siteContentApi } from "@/services/api/site-content-service.js";
|
||||||
|
import { openSiteContentTarget } from "@/utils/navigation/gateway.js";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
subpageMode: { type: Boolean, default: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
const subpageMode = computed(() => props.subpageMode);
|
||||||
|
|
||||||
|
const articleState = ref("loading");
|
||||||
|
const articleType = ref("");
|
||||||
|
const pageTitle = ref("传承资讯");
|
||||||
|
const articleError = ref("");
|
||||||
|
const openError = ref("");
|
||||||
|
const articles = ref([]);
|
||||||
|
const selectedArticle = ref(null);
|
||||||
|
const articleRequestController = createRequestController();
|
||||||
|
let pageActive = true;
|
||||||
|
|
||||||
|
const decodeRouteText = (value) => {
|
||||||
|
const text = String(value || "").trim();
|
||||||
|
if (!text) return "";
|
||||||
|
try {
|
||||||
|
return decodeURIComponent(text);
|
||||||
|
} catch {
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const headerTitle = computed(() => (subpageMode.value ? pageTitle.value : "代代相传"));
|
||||||
|
|
||||||
|
const pageDescription = computed(() =>
|
||||||
|
articleType.value === "notice"
|
||||||
|
? "平台发布的网站公告与服务通知"
|
||||||
|
: articleType.value === "news"
|
||||||
|
? "家谱文化、平台动态与最新资讯"
|
||||||
|
: "家谱文化、平台动态与实用文章",
|
||||||
|
);
|
||||||
|
|
||||||
|
const decodeArticleEntities = (value) =>
|
||||||
|
value
|
||||||
|
.replace(/ /gi, " ")
|
||||||
|
.replace(/&/gi, "&")
|
||||||
|
.replace(/</gi, "<")
|
||||||
|
.replace(/>/gi, ">")
|
||||||
|
.replace(/"/gi, '"')
|
||||||
|
.replace(/'/gi, "'");
|
||||||
|
|
||||||
|
const articlePlainText = (value) =>
|
||||||
|
decodeArticleEntities(
|
||||||
|
String(value || "")
|
||||||
|
.replace(/<br\s*\/?>/gi, "\n")
|
||||||
|
.replace(/<\/(?:p|div|li|h[1-6])>/gi, "\n")
|
||||||
|
.replace(/<[^>]*>/g, ""),
|
||||||
|
)
|
||||||
|
.replace(/\n{3,}/g, "\n\n")
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
const selectedArticleContent = computed(() =>
|
||||||
|
articlePlainText(selectedArticle.value?.content || selectedArticle.value?.summary) ||
|
||||||
|
"这篇文章暂时没有正文。",
|
||||||
|
);
|
||||||
|
|
||||||
|
const formatArticleDate = (value) => {
|
||||||
|
const text = String(value || "").trim();
|
||||||
|
return text.length >= 10 ? text.slice(0, 10) : text;
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadArticles = async () => {
|
||||||
|
articleRequestController.abort();
|
||||||
|
articleState.value = "loading";
|
||||||
|
articleError.value = "";
|
||||||
|
try {
|
||||||
|
const rows = await siteContentApi.getSiteArticles({
|
||||||
|
limit: 20,
|
||||||
|
...(articleType.value ? { articleType: articleType.value } : {}),
|
||||||
|
requestController: articleRequestController,
|
||||||
|
});
|
||||||
|
if (!pageActive) return;
|
||||||
|
articles.value = rows;
|
||||||
|
articleState.value = rows.length ? "list" : "empty";
|
||||||
|
} catch (error) {
|
||||||
|
if (!pageActive || isRequestCancelled(error)) return;
|
||||||
|
articleError.value = getRequestErrorMessage(error, "请稍后重新读取。");
|
||||||
|
articleState.value = "error";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const openArticle = async (article) => {
|
||||||
|
openError.value = "";
|
||||||
|
if (!article.externalUrl) {
|
||||||
|
selectedArticle.value = article;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await openSiteContentTarget(article.externalUrl, () => {
|
||||||
|
if (pageActive) openError.value = "外部文章暂时打不开,请稍后再试。";
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
if (pageActive) openError.value = "外部文章暂时打不开,请稍后再试。";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeArticle = () => {
|
||||||
|
selectedArticle.value = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
onLoad((query) => {
|
||||||
|
articleType.value = subpageMode.value && ["news", "notice"].includes(String(query?.articleType || ""))
|
||||||
|
? String(query.articleType)
|
||||||
|
: "";
|
||||||
|
pageTitle.value = subpageMode.value
|
||||||
|
? decodeRouteText(query?.title) ||
|
||||||
|
(articleType.value === "notice" ? "网站公告" : articleType.value === "news" ? "家谱新闻" : "文化传承文章")
|
||||||
|
: "传承资讯";
|
||||||
|
void loadArticles();
|
||||||
|
});
|
||||||
|
onUnload(() => {
|
||||||
|
pageActive = false;
|
||||||
|
articleRequestController.abort();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
@use "../../styles/adaptive-frame-profiles.scss" as adaptive;
|
||||||
|
|
||||||
|
.site-home-page {
|
||||||
|
display: flex;
|
||||||
|
height: 100vh;
|
||||||
|
min-height: 0;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
background: $paper;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-home-scroll {
|
||||||
|
z-index: 1;
|
||||||
|
width: 100%;
|
||||||
|
height: 0;
|
||||||
|
min-height: 0;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-home-content {
|
||||||
|
padding: 22rpx 24rpx calc(72rpx + env(safe-area-inset-bottom));
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-home-content--root {
|
||||||
|
padding-bottom: calc(166rpx + env(safe-area-inset-bottom));
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-home-heading text,
|
||||||
|
.site-article-card text,
|
||||||
|
.site-home-state text {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-home-heading text:first-child {
|
||||||
|
color: $ink;
|
||||||
|
font-family: "STKaiti", "KaiTi", serif;
|
||||||
|
font-size: clamp(20px, 36rpx, 25px);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-home-heading text:last-child {
|
||||||
|
margin-top: 6rpx;
|
||||||
|
color: $ink-muted;
|
||||||
|
font-size: clamp(14px, 22rpx, 17px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-article-list {
|
||||||
|
margin-top: 22rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-article-card {
|
||||||
|
@include adaptive.adaptive-family-letter;
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 210rpx;
|
||||||
|
margin: 0 0 18rpx;
|
||||||
|
padding: 28rpx 30rpx;
|
||||||
|
border: 0;
|
||||||
|
background-color: rgba($paper, 0.86);
|
||||||
|
box-sizing: border-box;
|
||||||
|
color: $ink;
|
||||||
|
line-height: 1.5;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-article-card::after {
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-article-card__meta {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 20rpx;
|
||||||
|
color: #946c48;
|
||||||
|
font-size: clamp(12px, 20rpx, 15px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-article-card__title {
|
||||||
|
margin-top: 10rpx;
|
||||||
|
color: $ink;
|
||||||
|
font-family: "STKaiti", "KaiTi", serif;
|
||||||
|
font-size: clamp(18px, 31rpx, 22px);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-article-card__summary {
|
||||||
|
display: -webkit-box;
|
||||||
|
margin-top: 9rpx;
|
||||||
|
overflow: hidden;
|
||||||
|
color: $ink-muted;
|
||||||
|
font-size: clamp(14px, 22rpx, 17px);
|
||||||
|
line-height: 1.55;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-article-card__action {
|
||||||
|
margin-top: 14rpx;
|
||||||
|
color: $brand-red;
|
||||||
|
font-size: clamp(13px, 21rpx, 16px);
|
||||||
|
font-weight: 700;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-home-state {
|
||||||
|
display: flex;
|
||||||
|
min-height: 420rpx;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: $ink-muted;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-home-state text:first-child {
|
||||||
|
color: $ink;
|
||||||
|
font-size: clamp(18px, 32rpx, 23px);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-home-state text + text {
|
||||||
|
margin-top: 12rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-home-state button {
|
||||||
|
min-height: 72rpx;
|
||||||
|
margin-top: 24rpx;
|
||||||
|
padding: 0 30rpx;
|
||||||
|
border: 1rpx solid rgba(159, 23, 15, 0.42);
|
||||||
|
border-radius: 36rpx;
|
||||||
|
background: rgba(255, 250, 240, 0.8);
|
||||||
|
color: $brand-red;
|
||||||
|
font-size: clamp(14px, 22rpx, 17px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-home-state button::after {
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-home-error {
|
||||||
|
display: block;
|
||||||
|
margin-top: 18rpx;
|
||||||
|
color: $brand-red;
|
||||||
|
font-size: clamp(13px, 21rpx, 16px);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-home-content :deep(.promotion-strip) {
|
||||||
|
margin-right: 0;
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,258 @@
|
|||||||
|
<template>
|
||||||
|
<view class="review-page">
|
||||||
|
<GenealogyPageBackground />
|
||||||
|
<view class="page-header">
|
||||||
|
<PageHeader title="申请审核" custom-back @back="returnToGenealogies" />
|
||||||
|
</view>
|
||||||
|
<view class="page-content">
|
||||||
|
<view v-if="!valid" class="state-card">
|
||||||
|
<text>暂时无法打开审核页面</text>
|
||||||
|
<text>未找到家谱信息,请返回后重新进入。</text>
|
||||||
|
<AppButton block label="返回我的家谱" @click="returnToGenealogies" />
|
||||||
|
</view>
|
||||||
|
<view v-else-if="applicationReviewState === 'loading'" class="state-card">
|
||||||
|
<AppLoading text="正在读取待审核申请" />
|
||||||
|
</view>
|
||||||
|
<view v-else-if="applicationReviewState === 'error'" class="state-card">
|
||||||
|
<text>暂时无法读取待审核申请</text>
|
||||||
|
<text>{{ applicationReviewError || "请检查网络后重试。" }}</text>
|
||||||
|
<AppButton block type="secondary" label="重新加载" @click="loadApplications" />
|
||||||
|
</view>
|
||||||
|
<view v-else-if="applicationReviewState === 'empty'" class="state-card">
|
||||||
|
<text>暂无待审核申请</text>
|
||||||
|
<text>新的加入申请会显示在这里。</text>
|
||||||
|
<AppButton block label="返回家谱总览" @click="returnToOverview" />
|
||||||
|
</view>
|
||||||
|
<view v-else class="review-list">
|
||||||
|
<text class="review-list__count">共 {{ applications.length }} 条待审核申请</text>
|
||||||
|
<text v-if="feedbackMessage" class="page-feedback" role="status">{{ feedbackMessage }}</text>
|
||||||
|
<view v-for="(item, index) in applications" :key="applicationKey(item, index)" class="review-card">
|
||||||
|
<view class="review-card__heading">
|
||||||
|
<text>{{ applicantName(item) }}</text>
|
||||||
|
<text>{{ applicationTime(item) }}</text>
|
||||||
|
</view>
|
||||||
|
<text v-if="applicantPhone(item)" class="review-card__phone">{{ applicantPhone(item) }}</text>
|
||||||
|
<text class="review-card__relation">{{ applicationRelation(item) }}</text>
|
||||||
|
<text v-if="applicationReason(item)" class="review-card__reason">{{ applicationReason(item) }}</text>
|
||||||
|
<view class="review-card__actions">
|
||||||
|
<AppButton compact type="secondary" label="拒绝" :disabled="!applicationId(item) || operationPending" @click="openAudit(item, JOIN_APPLICATION_STATUS.REJECTED)" />
|
||||||
|
<AppButton compact label="通过" :disabled="!applicationId(item) || operationPending" @click="openAudit(item, JOIN_APPLICATION_STATUS.APPROVED)" />
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<AppDialog
|
||||||
|
:visible="Boolean(auditTarget)"
|
||||||
|
eyebrow="申请审核"
|
||||||
|
:title="auditTarget?.decision === JOIN_APPLICATION_STATUS.APPROVED ? '通过这条申请?' : '拒绝这条申请?'"
|
||||||
|
:message="auditDialogMessage"
|
||||||
|
:confirm-text="operationPending ? '正在提交…' : auditTarget?.decision === JOIN_APPLICATION_STATUS.APPROVED ? '确认通过' : '确认拒绝'"
|
||||||
|
cancel-text="取消"
|
||||||
|
show-cancel
|
||||||
|
:close-on-mask="!operationPending"
|
||||||
|
@confirm="submitAudit"
|
||||||
|
@cancel="closeAudit"
|
||||||
|
>
|
||||||
|
<view v-if="auditTarget?.decision === JOIN_APPLICATION_STATUS.REJECTED" class="rejection-field">
|
||||||
|
<text>拒绝原因</text>
|
||||||
|
<textarea
|
||||||
|
id="application-rejection-reason"
|
||||||
|
v-model.trim="rejectionReason"
|
||||||
|
auto-height
|
||||||
|
maxlength="120"
|
||||||
|
placeholder="请说明需要补充或核实的信息"
|
||||||
|
:disabled="operationPending"
|
||||||
|
:aria-invalid="!!rejectionError"
|
||||||
|
aria-describedby="application-rejection-error"
|
||||||
|
:focus="rejectionFocused"
|
||||||
|
@input="clearRejectionError"
|
||||||
|
/>
|
||||||
|
<text v-if="rejectionError" id="application-rejection-error" class="rejection-field__error" role="alert">{{ rejectionError }}</text>
|
||||||
|
</view>
|
||||||
|
<text v-if="operationError" class="dialog-error" role="alert">{{ operationError }}</text>
|
||||||
|
</AppDialog>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, nextTick, ref } from "vue";
|
||||||
|
import { onBackPress, onLoad, onShow, onUnload } from "@dcloudio/uni-app";
|
||||||
|
import AppButton from "@/components/AppButton.vue";
|
||||||
|
import AppDialog from "@/components/AppDialog.vue";
|
||||||
|
import AppLoading from "@/components/AppLoading.vue";
|
||||||
|
import GenealogyPageBackground from "@/components/genealogy/PageBackground.vue";
|
||||||
|
import PageHeader from "@/components/PageHeader.vue";
|
||||||
|
import {
|
||||||
|
JOIN_APPLICATION_STATUS
|
||||||
|
} from "@/services/api/genealogy-membership-contract.js";
|
||||||
|
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 { handleBackPress, returnTo, runBackGuard } from "@/utils/navigation/gateway.js";
|
||||||
|
|
||||||
|
const genealogyId = ref("");
|
||||||
|
const applications = ref([]);
|
||||||
|
const applicationReviewState = ref("loading");
|
||||||
|
const applicationReviewError = ref("");
|
||||||
|
const auditTarget = ref(null);
|
||||||
|
const rejectionReason = ref("");
|
||||||
|
const rejectionError = ref("");
|
||||||
|
const rejectionFocused = ref(false);
|
||||||
|
const operationError = ref("");
|
||||||
|
const operationPending = ref(false);
|
||||||
|
const feedbackMessage = ref("");
|
||||||
|
const applicationListController = createRequestController();
|
||||||
|
const applicationAuditController = createRequestController();
|
||||||
|
let isPageActive = true;
|
||||||
|
|
||||||
|
const auditDialogMessage = computed(() => {
|
||||||
|
const name = applicantName(auditTarget.value?.item);
|
||||||
|
return auditTarget.value?.decision === JOIN_APPLICATION_STATUS.APPROVED
|
||||||
|
? `通过后,“${name}”将加入家谱。`
|
||||||
|
: "拒绝后,对方可在“我的申请”中查看处理结果。";
|
||||||
|
});
|
||||||
|
|
||||||
|
const valid = computed(() => /^[1-9]\d*$/.test(genealogyId.value));
|
||||||
|
const applicationId = (item) => {
|
||||||
|
const applicationIdValue = item?.applyId ?? item?.applicationId ?? item?.id;
|
||||||
|
const text = String(applicationIdValue ?? "").trim();
|
||||||
|
return /^[1-9]\d*$/.test(text) ? text : "";
|
||||||
|
};
|
||||||
|
const applicationKey = (item, index) => applicationId(item) || `application-${index}`;
|
||||||
|
const applicantName = (item) => String(item?.applicantName ?? item?.name ?? "申请人");
|
||||||
|
const applicantPhone = (item) => String(item?.phone ?? item?.mobile ?? "");
|
||||||
|
const applicationTime = (item) => String(item?.appliedAt ?? item?.applyTime ?? item?.createTime ?? item?.createdAt ?? "");
|
||||||
|
const applicationRelation = (item) => String(item?.relationDesc ?? item?.relation ?? "未填写关系说明");
|
||||||
|
const applicationReason = (item) => String(item?.applyReason ?? item?.reason ?? "");
|
||||||
|
|
||||||
|
const loadApplications = async () => {
|
||||||
|
if (!valid.value) return;
|
||||||
|
applicationListController.abort();
|
||||||
|
applicationReviewState.value = "loading";
|
||||||
|
applicationReviewError.value = "";
|
||||||
|
feedbackMessage.value = "";
|
||||||
|
try {
|
||||||
|
const loadedApplications = await genealogyMembershipApi.getPendingApplications(genealogyId.value, {
|
||||||
|
requestController: applicationListController,
|
||||||
|
});
|
||||||
|
if (!isPageActive) return;
|
||||||
|
applications.value = loadedApplications;
|
||||||
|
applicationReviewState.value = applications.value.length ? "ready" : "empty";
|
||||||
|
} catch (error) {
|
||||||
|
if (!isPageActive || isRequestCancelled(error)) return;
|
||||||
|
applicationReviewError.value = getRequestErrorMessage(error, "请稍后重试。");
|
||||||
|
applicationReviewState.value = "error";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const returnToGenealogies = () => returnTo("G01");
|
||||||
|
const returnToOverview = () => returnTo("G05", { genealogyId: genealogyId.value });
|
||||||
|
const clearRejectionError = () => {
|
||||||
|
rejectionError.value = "";
|
||||||
|
operationError.value = "";
|
||||||
|
};
|
||||||
|
const openAudit = (item, decision) => {
|
||||||
|
rejectionReason.value = "";
|
||||||
|
rejectionError.value = "";
|
||||||
|
rejectionFocused.value = false;
|
||||||
|
operationError.value = "";
|
||||||
|
auditTarget.value = { item, decision };
|
||||||
|
};
|
||||||
|
const resetAuditDialog = () => {
|
||||||
|
auditTarget.value = null;
|
||||||
|
rejectionReason.value = "";
|
||||||
|
rejectionError.value = "";
|
||||||
|
rejectionFocused.value = false;
|
||||||
|
operationError.value = "";
|
||||||
|
};
|
||||||
|
const closeAudit = () => {
|
||||||
|
if (operationPending.value) return;
|
||||||
|
resetAuditDialog();
|
||||||
|
};
|
||||||
|
const submitAudit = async () => {
|
||||||
|
const current = auditTarget.value;
|
||||||
|
const id = applicationId(current?.item);
|
||||||
|
if (!current || !id || operationPending.value) return;
|
||||||
|
if (current.decision === JOIN_APPLICATION_STATUS.REJECTED && !rejectionReason.value.trim()) {
|
||||||
|
rejectionError.value = "请填写拒绝原因,方便申请人补充资料。";
|
||||||
|
rejectionFocused.value = false;
|
||||||
|
await nextTick();
|
||||||
|
rejectionFocused.value = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
operationPending.value = true;
|
||||||
|
operationError.value = "";
|
||||||
|
try {
|
||||||
|
await genealogyMembershipApi.auditApplication(
|
||||||
|
genealogyId.value,
|
||||||
|
id,
|
||||||
|
{
|
||||||
|
status: current.decision,
|
||||||
|
...(current.decision === JOIN_APPLICATION_STATUS.REJECTED ? { auditRemark: rejectionReason.value.trim() } : {}),
|
||||||
|
},
|
||||||
|
{ requestController: applicationAuditController },
|
||||||
|
);
|
||||||
|
if (!isPageActive) return;
|
||||||
|
applications.value = applications.value.filter((item) => applicationId(item) !== id);
|
||||||
|
applicationReviewState.value = applications.value.length ? "ready" : "empty";
|
||||||
|
feedbackMessage.value = current.decision === JOIN_APPLICATION_STATUS.APPROVED ? "申请已通过。" : "申请已拒绝,并已附上审核说明。";
|
||||||
|
resetAuditDialog();
|
||||||
|
} catch (error) {
|
||||||
|
if (!isPageActive || isRequestCancelled(error)) return;
|
||||||
|
operationError.value = getRequestErrorMessage(error, "提交审核失败,请稍后重试。");
|
||||||
|
} finally {
|
||||||
|
if (isPageActive) operationPending.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const requestBack = () =>
|
||||||
|
runBackGuard({
|
||||||
|
transientOpen: Boolean(auditTarget.value),
|
||||||
|
submitting: operationPending.value,
|
||||||
|
"close-transient": closeAudit,
|
||||||
|
"block-submitting": () => true,
|
||||||
|
});
|
||||||
|
|
||||||
|
onLoad((query) => {
|
||||||
|
genealogyId.value = String(query?.genealogyId || "");
|
||||||
|
if (valid.value) loadApplications();
|
||||||
|
});
|
||||||
|
onShow(() => {
|
||||||
|
if (valid.value && applicationReviewState.value !== "loading" && !operationPending.value) loadApplications();
|
||||||
|
});
|
||||||
|
onBackPress((event) => handleBackPress(event, requestBack));
|
||||||
|
onUnload(() => {
|
||||||
|
isPageActive = false;
|
||||||
|
applicationListController.abort();
|
||||||
|
applicationAuditController.abort();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
@use "../../styles/adaptive-frame-profiles.scss" as adaptive;
|
||||||
|
.review-page { display: flex; min-height: 100vh; flex-direction: column; background: $paper; }
|
||||||
|
.page-header, .page-content { z-index: 1; }
|
||||||
|
.page-content { padding: 18rpx 24rpx calc(72rpx + env(safe-area-inset-bottom)); }
|
||||||
|
.state-card, .review-card { box-sizing: border-box; @include adaptive.adaptive-genealogy-state-panel; }
|
||||||
|
.state-card { min-height: 340rpx; padding: 78rpx 52rpx 50rpx; text-align: center; }
|
||||||
|
.state-card text, .review-card__phone, .review-card__relation, .review-card__reason, .page-feedback { display: block; }
|
||||||
|
.state-card text:first-child { color: $ink; font-family: STKaiti, KaiTi, serif; font-size: clamp(19px, 35rpx, 24px); font-weight: 700; }
|
||||||
|
.state-card text:nth-child(2) { margin-top: 18rpx; color: $ink-muted; font-size: clamp(15px, 24rpx, 18px); line-height: 1.65; }
|
||||||
|
.state-card .app-button { margin-top: 28rpx; }
|
||||||
|
.review-list { display: flex; flex-direction: column; gap: 16rpx; }
|
||||||
|
.review-list__count { color: $ink-muted; font-size: clamp(13px, 21rpx, 16px); }
|
||||||
|
.page-feedback { padding: 18rpx 22rpx; border: 1rpx solid rgba(66, 107, 88, .32); border-radius: 12rpx; background: rgba(66, 107, 88, .08); color: #426b58; font-size: clamp(14px, 23rpx, 17px); }
|
||||||
|
.review-card { padding: 28rpx 30rpx; }
|
||||||
|
.review-card__heading { display: flex; align-items: start; justify-content: space-between; gap: 20rpx; }
|
||||||
|
.review-card__heading text:first-child { min-width: 0; flex: 1; color: $ink; font-family: STKaiti, KaiTi, serif; font-size: clamp(18px, 32rpx, 23px); font-weight: 700; overflow-wrap: anywhere; }
|
||||||
|
.review-card__heading text:last-child { flex: 0 0 auto; color: $ink-muted; font-size: clamp(13px, 21rpx, 16px); text-align: right; }
|
||||||
|
.review-card__phone, .review-card__relation, .review-card__reason { margin-top: 12rpx; color: $ink-muted; font-size: clamp(15px, 24rpx, 18px); line-height: 1.55; overflow-wrap: anywhere; }
|
||||||
|
.review-card__reason { color: #725840; }
|
||||||
|
.review-card__actions { display: flex; justify-content: flex-end; margin-top: 22rpx; gap: 16rpx; }
|
||||||
|
.review-card__actions .app-button { min-width: 140rpx; }
|
||||||
|
.rejection-field { width: 100%; margin-top: 22rpx; text-align: left; }
|
||||||
|
.rejection-field > text:first-child { display: block; color: $ink; font-size: clamp(15px, 24rpx, 18px); font-weight: 700; }
|
||||||
|
.rejection-field textarea { @include adaptive.adaptive-genealogy-form-field; box-sizing: border-box; display: block; width: 100%; min-height: 118rpx; margin-top: 10rpx; padding: 16rpx 20rpx; color: $ink; font-size: clamp(15px, 24rpx, 18px); line-height: 1.55; }
|
||||||
|
.rejection-field__error, .dialog-error { display: block; width: 100%; margin-top: 10rpx; color: $brand-red; font-size: clamp(14px, 23rpx, 17px); line-height: 1.5; text-align: left; }
|
||||||
|
</style>
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
<template>
|
|
||||||
<view class="page-shell applications-page">
|
|
||||||
<PageHeader title="入谱审核" action="审核说明" @action="showHelp" />
|
|
||||||
<view class="audit-tip"><text>{{ loadError || '通过前请核实申请人的亲属关系;审核结果会通过通知告知对方。' }}</text></view>
|
|
||||||
<view class="application-list">
|
|
||||||
<view v-for="item in applications" :key="item.id" class="application-card paper-card">
|
|
||||||
<view class="application-top"><view><text class="applicant-name">{{ item.name }}</text><text class="applicant-phone">{{ item.phone }}</text></view><text class="applicant-time">{{ item.appliedAt }}</text></view>
|
|
||||||
<text class="relation-copy">{{ item.relation }}</text>
|
|
||||||
<view v-if="item.status === 'PENDING'" class="audit-actions"><button class="reject-button" @click="audit(item, false)">拒绝</button><button class="approve-button" @click="audit(item, true)">通过</button></view>
|
|
||||||
<text v-else class="audit-status">{{ item.status === 'APPROVED' ? '已通过' : '已拒绝' }}</text>
|
|
||||||
</view>
|
|
||||||
<view v-if="!applications.length && !loadError" class="empty-copy">暂无待审核申请</view>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { ref } from 'vue'
|
|
||||||
import { onLoad } from '@dcloudio/uni-app'
|
|
||||||
import PageHeader from '@/components/PageHeader.vue'
|
|
||||||
import { appApi } from '@/utils/api.js'
|
|
||||||
import { genealogyContext } from '@/utils/genealogy-context.js'
|
|
||||||
|
|
||||||
const applications = ref([])
|
|
||||||
const genealogyId = ref('')
|
|
||||||
const loadError = ref('')
|
|
||||||
|
|
||||||
onLoad(async (query) => {
|
|
||||||
genealogyId.value = query.genealogyId || genealogyContext.getCurrentGenealogyId()
|
|
||||||
if (!genealogyId.value) { loadError.value = '未找到当前家谱,请从家谱总览进入。'; return }
|
|
||||||
genealogyContext.setCurrentGenealogyId(genealogyId.value)
|
|
||||||
try {
|
|
||||||
applications.value = await appApi.getPendingApplications(genealogyId.value)
|
|
||||||
} catch (error) {
|
|
||||||
loadError.value = error.message || '审核列表加载失败。'
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const audit = async (item, approved) => {
|
|
||||||
try {
|
|
||||||
await appApi.auditApplication(genealogyId.value, item.id, approved)
|
|
||||||
item.status = approved ? 'APPROVED' : 'REJECTED'
|
|
||||||
uni.showToast({ title: approved ? '已通过申请' : '已拒绝申请', icon: 'none' })
|
|
||||||
} catch (error) {
|
|
||||||
uni.showToast({ title: error.message || '审核失败,请稍后重试', icon: 'none' })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const showHelp = () => uni.showModal({ title: '审核说明', content: '仅确认与本家谱存在真实亲属关系的申请,避免错误入谱。', showCancel: false })
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped lang="scss">
|
|
||||||
.applications-page { padding-bottom: 36rpx; }
|
|
||||||
.audit-tip { margin: 26rpx 28rpx 0; padding: 24rpx; border-left: 5rpx solid $brand-red; background: #efe3cd; color: $ink-muted; font-size: 24rpx; line-height: 1.6; }
|
|
||||||
.application-list { display: flex; flex-direction: column; gap: 20rpx; margin: 24rpx 28rpx; }
|
|
||||||
.application-card { padding: 28rpx; }
|
|
||||||
.application-top { display: flex; justify-content: space-between; }
|
|
||||||
.applicant-name { color: $ink; font-size: 32rpx; font-weight: 700; }
|
|
||||||
.applicant-phone { margin-left: 16rpx; color: $ink-muted; font-size: 22rpx; }
|
|
||||||
.applicant-time { color: #a39583; font-size: 21rpx; }
|
|
||||||
.relation-copy { display: block; margin-top: 16rpx; color: $ink-muted; font-size: 25rpx; }
|
|
||||||
.audit-actions { display: flex; justify-content: flex-end; gap: 16rpx; margin-top: 24rpx; }
|
|
||||||
.audit-actions button { min-width: 128rpx; min-height: 64rpx; margin: 0; border-radius: 8rpx; font-size: 24rpx; }
|
|
||||||
.reject-button { border: 1rpx solid $gold-soft; background: #fffaf0; color: $ink-muted; }
|
|
||||||
.approve-button { border: 1rpx solid $brand-red; background: $brand-red; color: #fff9ed; }
|
|
||||||
.audit-status { display: block; margin-top: 24rpx; color: $brand-red; font-size: 24rpx; text-align: right; }
|
|
||||||
.empty-copy { padding: 80rpx 0; color: $ink-muted; font-size: 26rpx; text-align: center; }
|
|
||||||
</style>
|
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
<template>
|
||||||
|
<view class="capability-page">
|
||||||
|
<GenealogyPageBackground />
|
||||||
|
<view class="page-header"><PageHeader title="资料与回收站" custom-back @back="goBack" /></view>
|
||||||
|
<view class="page-content">
|
||||||
|
<view class="tab-row">
|
||||||
|
<button :class="['tab', { active: tab === 'completeness' }]" @click="tab = 'completeness'">资料完整度</button>
|
||||||
|
<button :class="['tab', { active: tab === 'recycle' }]" @click="tab = 'recycle'">内容回收站</button>
|
||||||
|
</view>
|
||||||
|
<view v-if="loading" class="state-card"><AppLoading text="正在读取家谱资料" /></view>
|
||||||
|
<view v-else-if="error" class="state-card"><text>{{ error }}</text><AppButton block label="重新加载" @click="load" /></view>
|
||||||
|
<view v-else-if="tab === 'completeness' && !hasValidCompleteness" class="state-card"><text>{{ completenessError || '资料完整度暂时无法读取。' }}</text><AppButton block label="重新加载" @click="load" /></view>
|
||||||
|
<view v-else-if="tab === 'completeness'" class="panel">
|
||||||
|
<text class="panel-title">资料完成度 {{ completeness?.completionRate || 0 }}%</text>
|
||||||
|
<text class="panel-copy">已完成 {{ completeness?.completedCount || 0 }} / {{ completeness?.totalCount || 0 }} 项</text>
|
||||||
|
<view v-if="!completeness?.missingItems?.length" class="empty-copy">这部家谱的基础资料已补充完整。</view>
|
||||||
|
<view v-for="item in completeness?.missingItems || []" :key="item.code" class="row"><text>{{ item.displayText }}</text><text>待补充</text></view>
|
||||||
|
</view>
|
||||||
|
<view v-else-if="recycleError" class="state-card"><text>{{ recycleError }}</text><AppButton block label="重新加载" @click="load" /></view>
|
||||||
|
<view v-else class="panel">
|
||||||
|
<text class="panel-title">已删除内容</text>
|
||||||
|
<text class="panel-copy">可恢复的内容会显示“恢复”按钮。</text>
|
||||||
|
<view v-if="!recycle.rows.length" class="empty-copy">回收站暂时没有内容。</view>
|
||||||
|
<view v-for="item in recycle.rows" :key="`${item.resourceType}-${item.resourceId}`" class="row row--recycle">
|
||||||
|
<view><text>{{ item.resourceTitle || '未命名内容' }}</text><text class="row-meta">{{ item.resourceType }} · {{ item.deletedAt || '删除时间未知' }}</text></view>
|
||||||
|
<AppButton v-if="item.canRestore" class="restore-button" compact type="secondary" :disabled="restoringKey === `${item.resourceType}-${item.resourceId}`" label="恢复" @click="restore(item)" />
|
||||||
|
<text v-else class="row-meta">{{ recycleDisabledReason(item.disabledReason) }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import { onLoad, onUnload } from '@dcloudio/uni-app'
|
||||||
|
import AppButton from '@/components/AppButton.vue'
|
||||||
|
import AppLoading from '@/components/AppLoading.vue'
|
||||||
|
import PageHeader from '@/components/PageHeader.vue'
|
||||||
|
import GenealogyPageBackground from '@/components/genealogy/PageBackground.vue'
|
||||||
|
import { genealogyCapabilityApi } from '@/services/api/genealogy-capability-service.js'
|
||||||
|
import { createRequestController, isRequestCancelled } from '@/services/api/request-controller.js'
|
||||||
|
import { getRequestErrorMessage } from '@/services/api/request-error-message.js'
|
||||||
|
import { goBack } from '@/utils/navigation/gateway.js'
|
||||||
|
|
||||||
|
const genealogyId = ref('')
|
||||||
|
const tab = ref('completeness')
|
||||||
|
const loading = ref(true)
|
||||||
|
const error = ref('')
|
||||||
|
const completenessError = ref('')
|
||||||
|
const recycleError = ref('')
|
||||||
|
const completeness = ref(null)
|
||||||
|
const recycle = ref({ rows: [], total: 0 })
|
||||||
|
const restoringKey = ref('')
|
||||||
|
const controller = createRequestController()
|
||||||
|
const recycleDisabledReason = (reason) => reason === 'ALREADY_RESTORED' ? '已恢复' : '不可恢复'
|
||||||
|
const hasValidCompleteness = computed(() => (
|
||||||
|
Number.isSafeInteger(completeness.value?.totalCount) &&
|
||||||
|
completeness.value.totalCount > 0 &&
|
||||||
|
Number.isSafeInteger(completeness.value?.completedCount) &&
|
||||||
|
completeness.value.completedCount >= 0 &&
|
||||||
|
completeness.value.completedCount <= completeness.value.totalCount &&
|
||||||
|
Number.isSafeInteger(completeness.value?.completionRate) &&
|
||||||
|
completeness.value.completionRate >= 0 &&
|
||||||
|
completeness.value.completionRate <= 100 &&
|
||||||
|
Array.isArray(completeness.value?.missingItems)
|
||||||
|
))
|
||||||
|
|
||||||
|
const load = async () => {
|
||||||
|
if (!/^[1-9]\d*$/.test(genealogyId.value)) { error.value = '缺少有效家谱信息'; loading.value = false; return }
|
||||||
|
controller.abort(); loading.value = true; error.value = ''; completenessError.value = ''; recycleError.value = ''
|
||||||
|
const [completenessResult, recycleResult] = await Promise.allSettled([
|
||||||
|
genealogyCapabilityApi.getCompleteness(genealogyId.value, { requestController: controller }),
|
||||||
|
genealogyCapabilityApi.getRecyclePage(genealogyId.value, { pageNum: 1, pageSize: 50, recycleStatus: 'OPEN' }, { requestController: controller })
|
||||||
|
])
|
||||||
|
if (completenessResult.status === 'fulfilled') completeness.value = completenessResult.value
|
||||||
|
else if (!isRequestCancelled(completenessResult.reason)) {
|
||||||
|
completeness.value = null
|
||||||
|
completenessError.value = getRequestErrorMessage(completenessResult.reason, '资料完整度暂时无法读取。')
|
||||||
|
}
|
||||||
|
if (recycleResult.status === 'fulfilled') recycle.value = recycleResult.value
|
||||||
|
else if (!isRequestCancelled(recycleResult.reason)) {
|
||||||
|
recycle.value = { rows: [], total: 0 }
|
||||||
|
recycleError.value = getRequestErrorMessage(recycleResult.reason, '回收站暂时无法读取。')
|
||||||
|
}
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const restore = async (item) => {
|
||||||
|
const key = `${item.resourceType}-${item.resourceId}`
|
||||||
|
if (restoringKey.value) return
|
||||||
|
restoringKey.value = key
|
||||||
|
try { await genealogyCapabilityApi.restoreRecycleItem(genealogyId.value, item.resourceType, item.resourceId); await load() }
|
||||||
|
catch (requestError) { recycleError.value = getRequestErrorMessage(requestError, '恢复失败,请稍后重试。') }
|
||||||
|
finally { restoringKey.value = '' }
|
||||||
|
}
|
||||||
|
|
||||||
|
onLoad((query) => { genealogyId.value = String(query?.genealogyId || ''); void load() })
|
||||||
|
onUnload(() => controller.abort())
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.capability-page { min-height: 100vh; color: $ink; }
|
||||||
|
.page-header { position: relative; z-index: 1; }
|
||||||
|
.page-content { position: relative; z-index: 1; padding: 24rpx 28rpx calc(72rpx + env(safe-area-inset-bottom)); }
|
||||||
|
.tab-row { display: flex; gap: 16rpx; margin-bottom: 20rpx; }
|
||||||
|
.tab { flex: 1; min-height: 80rpx; border: 1rpx solid rgba($gold, .38); border-radius: 12rpx; background: rgba(255,252,245,.88); color: $ink-muted; font-size: 26rpx; }
|
||||||
|
.tab.active { border-color: $brand-red; background: $brand-red; color: #fff; }
|
||||||
|
.panel,.state-card { padding: 28rpx; border: 1rpx solid rgba($gold, .38); border-radius: 16rpx; background: rgba(255,252,245,.92); }
|
||||||
|
.panel-title,.panel-copy,.row text,.empty-copy { display: block; }
|
||||||
|
.panel-title { font-size: 32rpx; font-weight: 700; }.panel-copy,.row-meta,.empty-copy { margin-top: 12rpx; color: $ink-muted; font-size: 25rpx; }
|
||||||
|
.row { display:flex; justify-content:space-between; align-items:center; gap:16rpx; padding:24rpx 0; border-top:1rpx solid rgba($gold, .38); }.row:first-of-type { margin-top:20rpx; }.row--recycle > view { flex:1; min-width:0; }
|
||||||
|
.restore-button { flex: 0 0 176rpx; width: 176rpx; }
|
||||||
|
</style>
|
||||||
@@ -1,68 +1,701 @@
|
|||||||
<template>
|
<template>
|
||||||
<view class="page-shell create-page">
|
<view class="create-page">
|
||||||
<PageHeader title="创建家谱" />
|
<GenealogyPageBackground />
|
||||||
<view class="form-paper">
|
<PageHeader title="创建家谱" custom-back @back="requestBack" />
|
||||||
<view class="section-title">立谱信息</view>
|
|
||||||
<text class="form-note">家谱建立后可继续完善,名称与访问规则由创建者维护。</text>
|
<view class="page-content">
|
||||||
|
<view class="create-card">
|
||||||
|
<text class="create-card__eyebrow">立谱信息</text>
|
||||||
|
<text class="create-card__title">为家族创建一部家谱</text>
|
||||||
|
<text class="create-card__note"
|
||||||
|
>创建时会同步建立始迁祖人物;创建成功后会回到“我的家谱”。</text
|
||||||
|
>
|
||||||
|
|
||||||
<view class="field-row">
|
<view class="field-row">
|
||||||
<text>姓氏</text>
|
<text class="field-row__label"
|
||||||
<input v-model="form.surname" maxlength="4" placeholder="如:汤" placeholder-class="placeholder" />
|
><text class="required-mark">*</text>姓氏</text
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
v-model="form.surname"
|
||||||
|
maxlength="4"
|
||||||
|
placeholder="请输入姓氏"
|
||||||
|
placeholder-class="placeholder"
|
||||||
|
@input="clearFieldError('surname')"
|
||||||
|
/>
|
||||||
</view>
|
</view>
|
||||||
|
<text v-if="fieldErrors.surname" class="field-error">{{
|
||||||
|
fieldErrors.surname
|
||||||
|
}}</text>
|
||||||
|
|
||||||
<view class="field-row">
|
<view class="field-row">
|
||||||
<text>谱名</text>
|
<text class="field-row__label"
|
||||||
<input v-model="form.name" maxlength="24" placeholder="如:四川武胜汤氏族谱" placeholder-class="placeholder" />
|
><text class="required-mark">*</text>谱名</text
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
v-model="form.genealogyName"
|
||||||
|
maxlength="24"
|
||||||
|
placeholder="请输入家谱名称"
|
||||||
|
placeholder-class="placeholder"
|
||||||
|
@input="clearFieldError('genealogyName')"
|
||||||
|
/>
|
||||||
</view>
|
</view>
|
||||||
<view class="field-row">
|
<text v-if="fieldErrors.genealogyName" class="field-error">{{
|
||||||
<text>堂号</text>
|
fieldErrors.genealogyName
|
||||||
<input v-model="form.hall" maxlength="12" placeholder="选填" placeholder-class="placeholder" />
|
}}</text>
|
||||||
|
|
||||||
|
<view
|
||||||
|
class="field-row field-row--selector"
|
||||||
|
role="button"
|
||||||
|
aria-label="选择所在地区"
|
||||||
|
@click="openRegionPicker"
|
||||||
|
>
|
||||||
|
<text class="field-row__label"
|
||||||
|
><text class="required-mark">*</text>所在地区</text
|
||||||
|
>
|
||||||
|
<text
|
||||||
|
class="region-selector-value"
|
||||||
|
:class="{ 'region-selector-value--placeholder': !selectedRegion }"
|
||||||
|
>{{
|
||||||
|
selectedRegion
|
||||||
|
? regionPickerTrail.map((item) => item.label).join(" / ")
|
||||||
|
: regionLoading
|
||||||
|
? "正在加载地区…"
|
||||||
|
: regionPickerError || "请选择所在地区"
|
||||||
|
}}</text
|
||||||
|
>
|
||||||
|
<image
|
||||||
|
class="region-selector-chevron"
|
||||||
|
src="/static/assets/foundation/transparent/chevron-right.png"
|
||||||
|
mode="aspectFit"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
</view>
|
</view>
|
||||||
|
<text v-if="fieldErrors.regionCode" class="field-error">{{
|
||||||
|
fieldErrors.regionCode
|
||||||
|
}}</text>
|
||||||
|
|
||||||
<view class="field-row">
|
<view class="field-row">
|
||||||
<text>所在地</text>
|
<text class="field-row__label">堂号</text>
|
||||||
<input v-model="form.location" maxlength="30" placeholder="请选择或输入" placeholder-class="placeholder" />
|
<input
|
||||||
|
v-model="form.ancestralHall"
|
||||||
|
maxlength="12"
|
||||||
|
placeholder="请输入堂号"
|
||||||
|
placeholder-class="placeholder"
|
||||||
|
/>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="field-row">
|
||||||
|
<text class="field-row__label"
|
||||||
|
><text class="required-mark">*</text>始迁祖</text
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
v-model="form.firstAncestorName"
|
||||||
|
maxlength="30"
|
||||||
|
placeholder="请输入始迁祖姓名"
|
||||||
|
placeholder-class="placeholder"
|
||||||
|
@input="clearFieldError('firstAncestorName')"
|
||||||
|
/>
|
||||||
|
</view>
|
||||||
|
<text v-if="fieldErrors.firstAncestorName" class="field-error">{{
|
||||||
|
fieldErrors.firstAncestorName
|
||||||
|
}}</text>
|
||||||
|
|
||||||
|
<view class="owner-ancestor-field">
|
||||||
|
<view>
|
||||||
|
<text class="owner-ancestor-field__label">谱主本人就是始迁祖</text>
|
||||||
|
<text class="owner-ancestor-field__hint"
|
||||||
|
>仅本人确为始迁祖时开启,开启后账号会绑定到该人物。</text
|
||||||
|
>
|
||||||
|
</view>
|
||||||
|
<switch
|
||||||
|
:checked="form.ownerIsFirstAncestor"
|
||||||
|
color="#9f170f"
|
||||||
|
aria-label="谱主本人就是始迁祖"
|
||||||
|
@change="form.ownerIsFirstAncestor = $event.detail.value"
|
||||||
|
/>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="field-row">
|
||||||
|
<text class="field-row__label">所在地</text>
|
||||||
|
<input
|
||||||
|
v-model="form.originPlace"
|
||||||
|
maxlength="30"
|
||||||
|
placeholder="请输入所在地"
|
||||||
|
placeholder-class="placeholder"
|
||||||
|
/>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="field-row">
|
||||||
|
<text class="field-row__label">详细地址</text>
|
||||||
|
<input
|
||||||
|
v-model="form.addressDetail"
|
||||||
|
maxlength="60"
|
||||||
|
placeholder="请输入详细地址"
|
||||||
|
placeholder-class="placeholder"
|
||||||
|
/>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="intro-field">
|
||||||
|
<text class="intro-field__label">家谱简介</text>
|
||||||
|
<textarea
|
||||||
|
v-model="form.intro"
|
||||||
|
maxlength="200"
|
||||||
|
auto-height
|
||||||
|
placeholder="可记录迁徙、家训或建谱缘由"
|
||||||
|
placeholder-class="placeholder"
|
||||||
|
/>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="cover-field">
|
||||||
|
<view>
|
||||||
|
<text class="cover-field__label">封面图片</text>
|
||||||
|
<text class="cover-field__hint"
|
||||||
|
>图片上传成功后,会作为家谱封面保存。</text
|
||||||
|
>
|
||||||
|
</view>
|
||||||
|
<button
|
||||||
|
class="upload-button"
|
||||||
|
:disabled="isUploading || isSubmitting"
|
||||||
|
@click="uploadCover"
|
||||||
|
>
|
||||||
|
{{ isUploading ? "上传中…" : "选择图片" }}
|
||||||
|
</button>
|
||||||
|
<text v-if="coverFileName" class="upload-receipt"
|
||||||
|
>已上传:{{ coverFileName }}</text
|
||||||
|
>
|
||||||
|
</view>
|
||||||
|
<text v-if="uploadError" class="field-error">{{ uploadError }}</text>
|
||||||
|
|
||||||
|
<view class="access-rule">
|
||||||
|
<text class="access-rule__label">访问规则</text>
|
||||||
|
<view
|
||||||
|
class="access-rule__options"
|
||||||
|
role="radiogroup"
|
||||||
|
aria-label="家谱访问规则"
|
||||||
|
>
|
||||||
|
<view
|
||||||
|
v-for="option in GENEALOGY_ACCESS_PRESET_OPTIONS"
|
||||||
|
:key="option.value"
|
||||||
|
class="access-rule__option"
|
||||||
|
:class="{
|
||||||
|
'access-rule__option--active':
|
||||||
|
form.accessPreset === option.value,
|
||||||
|
}"
|
||||||
|
role="radio"
|
||||||
|
:aria-checked="form.accessPreset === option.value"
|
||||||
|
:aria-label="option.label"
|
||||||
|
@click="form.accessPreset = option.value"
|
||||||
|
>{{ option.label }}</view
|
||||||
|
>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view class="privacy-paper">
|
<text v-if="submitError" class="submit-error">{{ submitError }}</text>
|
||||||
<view class="privacy-heading">
|
<AppButton
|
||||||
<text class="section-title">访问规则</text>
|
block
|
||||||
<text class="privacy-value">默认仅成员可见</text>
|
:disabled="isSubmitting || isUploading"
|
||||||
|
:label="isSubmitting ? '正在创建…' : createdGenealogyId ? '返回我的家谱' : '确认创建家谱'"
|
||||||
|
@click="submitCreate"
|
||||||
|
/>
|
||||||
</view>
|
</view>
|
||||||
<text class="form-note">保护族人资料。创建者可在家谱设置中开放搜索与申请加入。</text>
|
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view class="bottom-action"><button class="primary-button" @click="submit">创建并录入首代</button></view>
|
<RegionPickerDialog
|
||||||
|
ref="regionPickerDialog"
|
||||||
|
:max-levels="3"
|
||||||
|
@select="selectRegion"
|
||||||
|
@loading-change="regionLoading = $event"
|
||||||
|
@error-change="regionPickerError = $event"
|
||||||
|
@transient-change="regionPickerTransientOpen = $event"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<AppDialog
|
||||||
|
:visible="discardDialogVisible"
|
||||||
|
eyebrow="尚未保存"
|
||||||
|
title="要放弃本次创建吗"
|
||||||
|
message="返回后,本次填写的家谱信息不会保留。"
|
||||||
|
cancel-text="继续填写"
|
||||||
|
confirm-text="放弃并返回"
|
||||||
|
show-cancel
|
||||||
|
:close-on-mask="false"
|
||||||
|
@cancel="cancelDiscard"
|
||||||
|
@confirm="confirmDiscard"
|
||||||
|
/>
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { reactive } from 'vue'
|
import { computed, onMounted, reactive, ref } from "vue";
|
||||||
import PageHeader from '@/components/PageHeader.vue'
|
import { onBackPress, onUnload } from "@dcloudio/uni-app";
|
||||||
import { appApi } from '@/utils/api.js'
|
import AppButton from "@/components/AppButton.vue";
|
||||||
import { genealogyContext } from '@/utils/genealogy-context.js'
|
import AppDialog from "@/components/AppDialog.vue";
|
||||||
|
import GenealogyPageBackground from "@/components/genealogy/PageBackground.vue";
|
||||||
|
import RegionPickerDialog from "@/components/genealogy/RegionPickerDialog.vue";
|
||||||
|
import PageHeader from "@/components/PageHeader.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";
|
||||||
|
import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
|
||||||
|
import {
|
||||||
|
GENEALOGY_ACCESS_PRESET,
|
||||||
|
GENEALOGY_ACCESS_PRESET_OPTIONS,
|
||||||
|
toApiGenealogyAccess,
|
||||||
|
} from "@/utils/genealogy/access-policy.js";
|
||||||
|
import {
|
||||||
|
isImagePickCancelled,
|
||||||
|
pickAndUploadImage,
|
||||||
|
} from "@/utils/media-upload.js";
|
||||||
|
import {
|
||||||
|
finishPage,
|
||||||
|
handleBackPress,
|
||||||
|
returnTo,
|
||||||
|
runBackGuard,
|
||||||
|
} from "@/utils/navigation/gateway.js";
|
||||||
|
|
||||||
const form = reactive({ surname: '', name: '', hall: '', location: '', visibility: 'MEMBER_ONLY' })
|
const form = reactive({
|
||||||
|
surname: "",
|
||||||
|
genealogyName: "",
|
||||||
|
ancestralHall: "",
|
||||||
|
firstAncestorName: "",
|
||||||
|
ownerIsFirstAncestor: false,
|
||||||
|
originPlace: "",
|
||||||
|
addressDetail: "",
|
||||||
|
intro: "",
|
||||||
|
accessPreset: GENEALOGY_ACCESS_PRESET.MEMBER_ONLY,
|
||||||
|
});
|
||||||
|
const fieldErrors = reactive({
|
||||||
|
surname: "",
|
||||||
|
genealogyName: "",
|
||||||
|
firstAncestorName: "",
|
||||||
|
regionCode: "",
|
||||||
|
});
|
||||||
|
const submitError = ref("");
|
||||||
|
const isSubmitting = ref(false);
|
||||||
|
const createdGenealogyId = ref("");
|
||||||
|
const isUploading = ref(false);
|
||||||
|
const uploadError = ref("");
|
||||||
|
const coverOssId = ref(null);
|
||||||
|
const coverFileName = ref("");
|
||||||
|
const coverUploadRequestController = createRequestController();
|
||||||
|
const genealogyCreateRequestController = createRequestController();
|
||||||
|
const createGenealogyRequestId = () =>
|
||||||
|
typeof globalThis.crypto?.randomUUID === "function"
|
||||||
|
? `app-genealogy-${globalThis.crypto.randomUUID()}`
|
||||||
|
: `app-genealogy-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||||
|
const genealogyCreateRequestId = ref(createGenealogyRequestId());
|
||||||
|
const selectedRegion = ref(null);
|
||||||
|
const regionPickerTrail = ref([]);
|
||||||
|
const regionPickerDialog = ref(null);
|
||||||
|
const regionPickerError = ref("");
|
||||||
|
const regionLoading = ref(false);
|
||||||
|
const regionPickerTransientOpen = ref(false);
|
||||||
|
const discardDialogVisible = ref(false);
|
||||||
|
let pageActive = true;
|
||||||
|
const hasDraft = computed(
|
||||||
|
() =>
|
||||||
|
Object.entries(form).some(([key, value]) =>
|
||||||
|
key === "accessPreset"
|
||||||
|
? value !== GENEALOGY_ACCESS_PRESET.MEMBER_ONLY
|
||||||
|
: key === "ownerIsFirstAncestor"
|
||||||
|
? value === true
|
||||||
|
: String(value).trim(),
|
||||||
|
) ||
|
||||||
|
Boolean(selectedRegion.value?.regionCode) ||
|
||||||
|
Boolean(coverOssId.value),
|
||||||
|
);
|
||||||
|
const discardConfirmation = createDiscardConfirmation((visible) => {
|
||||||
|
discardDialogVisible.value = visible;
|
||||||
|
});
|
||||||
|
const requestDiscardConfirmation = discardConfirmation.request;
|
||||||
|
const confirmDiscard = discardConfirmation.confirm;
|
||||||
|
const cancelDiscard = discardConfirmation.cancel;
|
||||||
|
|
||||||
const submit = async () => {
|
const clearFieldError = (field) => {
|
||||||
if (!form.surname.trim() || !form.name.trim()) {
|
fieldErrors[field] = "";
|
||||||
uni.showToast({ title: '请填写姓氏和谱名', icon: 'none' })
|
submitError.value = "";
|
||||||
return
|
};
|
||||||
|
|
||||||
|
const validate = () => {
|
||||||
|
fieldErrors.surname = form.surname.trim() ? "" : "请填写姓氏";
|
||||||
|
fieldErrors.genealogyName = form.genealogyName.trim() ? "" : "请填写谱名";
|
||||||
|
fieldErrors.firstAncestorName = form.firstAncestorName.trim()
|
||||||
|
? ""
|
||||||
|
: "请填写始迁祖姓名";
|
||||||
|
fieldErrors.regionCode = selectedRegion.value?.regionCode
|
||||||
|
? ""
|
||||||
|
: "请选择所在地区";
|
||||||
|
return (
|
||||||
|
!fieldErrors.surname &&
|
||||||
|
!fieldErrors.genealogyName &&
|
||||||
|
!fieldErrors.firstAncestorName &&
|
||||||
|
!fieldErrors.regionCode
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openRegionPicker = async () => {
|
||||||
|
if (isSubmitting.value || isUploading.value || regionLoading.value) return;
|
||||||
|
await regionPickerDialog.value?.open(selectedRegion.value?.regionCode || "");
|
||||||
|
};
|
||||||
|
const selectRegion = ({ region, trail }) => {
|
||||||
|
selectedRegion.value = region;
|
||||||
|
regionPickerTrail.value = trail;
|
||||||
|
fieldErrors.regionCode = "";
|
||||||
|
regionPickerError.value = "";
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
void regionPickerDialog.value?.prepare();
|
||||||
|
});
|
||||||
|
|
||||||
|
const requestBack = () =>
|
||||||
|
createdGenealogyId.value
|
||||||
|
? returnTo("G01")
|
||||||
|
: runBackGuard({
|
||||||
|
transientOpen:
|
||||||
|
regionPickerTransientOpen.value || discardDialogVisible.value,
|
||||||
|
dirty: hasDraft.value,
|
||||||
|
submitting: isSubmitting.value || isUploading.value,
|
||||||
|
"close-transient": () =>
|
||||||
|
regionPickerTransientOpen.value
|
||||||
|
? regionPickerDialog.value?.close()
|
||||||
|
: cancelDiscard(),
|
||||||
|
"block-submitting": () => true,
|
||||||
|
"confirm-discard": requestDiscardConfirmation,
|
||||||
|
});
|
||||||
|
|
||||||
|
onBackPress((event) => handleBackPress(event, requestBack));
|
||||||
|
|
||||||
|
const uploadCover = async () => {
|
||||||
|
if (isUploading.value || isSubmitting.value) return;
|
||||||
|
isUploading.value = true;
|
||||||
|
uploadError.value = "";
|
||||||
|
try {
|
||||||
|
const receipt = await pickAndUploadImage({
|
||||||
|
requestController: coverUploadRequestController,
|
||||||
|
});
|
||||||
|
if (!pageActive) return;
|
||||||
|
coverOssId.value = receipt.ossId;
|
||||||
|
coverFileName.value = receipt.fileName || "封面图片";
|
||||||
|
} catch (error) {
|
||||||
|
if (
|
||||||
|
pageActive &&
|
||||||
|
!isImagePickCancelled(error) &&
|
||||||
|
!isRequestCancelled(error)
|
||||||
|
) {
|
||||||
|
uploadError.value = getRequestErrorMessage(error, "封面图片上传失败,请稍后重试");
|
||||||
}
|
}
|
||||||
const created = await appApi.createGenealogy({ ...form })
|
} finally {
|
||||||
genealogyContext.setCurrentGenealogyId(created.id)
|
if (pageActive) isUploading.value = false;
|
||||||
uni.redirectTo({ url: `/pages/lineage/first-person?genealogyId=${created.id}` })
|
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const submitCreate = async () => {
|
||||||
|
if (isSubmitting.value || isUploading.value) return;
|
||||||
|
if (!createdGenealogyId.value && !validate()) return;
|
||||||
|
|
||||||
|
let createPayload = null;
|
||||||
|
if (!createdGenealogyId.value) {
|
||||||
|
const access = toApiGenealogyAccess(form.accessPreset);
|
||||||
|
if (!access) {
|
||||||
|
submitError.value = "请选择家谱开放方式";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
createPayload = {
|
||||||
|
surname: form.surname,
|
||||||
|
genealogyName: form.genealogyName,
|
||||||
|
regionCode: selectedRegion.value.regionCode,
|
||||||
|
ancestralHall: form.ancestralHall,
|
||||||
|
firstAncestorName: form.firstAncestorName,
|
||||||
|
ownerIsFirstAncestor: form.ownerIsFirstAncestor,
|
||||||
|
requestId: genealogyCreateRequestId.value,
|
||||||
|
originPlace: form.originPlace,
|
||||||
|
addressDetail: form.addressDetail,
|
||||||
|
intro: form.intro,
|
||||||
|
coverOssId: coverOssId.value,
|
||||||
|
...access,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
isSubmitting.value = true;
|
||||||
|
submitError.value = "";
|
||||||
|
try {
|
||||||
|
if (!createdGenealogyId.value) {
|
||||||
|
const created = await genealogyApi.createGenealogy(
|
||||||
|
createPayload,
|
||||||
|
{ requestController: genealogyCreateRequestController },
|
||||||
|
);
|
||||||
|
if (!pageActive) return;
|
||||||
|
createdGenealogyId.value = created.id;
|
||||||
|
}
|
||||||
|
await finishPage(
|
||||||
|
"G01",
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
operation: "genealogy-created",
|
||||||
|
entityId: createdGenealogyId.value,
|
||||||
|
refresh: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
if (!pageActive) return;
|
||||||
|
if (isRequestCancelled(error)) return;
|
||||||
|
submitError.value = createdGenealogyId.value
|
||||||
|
? "家谱已经创建,但页面返回失败。请再次点击“返回我的家谱”,不要重复创建。"
|
||||||
|
: getRequestErrorMessage(error, "创建失败,请稍后重试");
|
||||||
|
} finally {
|
||||||
|
if (pageActive) isSubmitting.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
onUnload(() => {
|
||||||
|
pageActive = false;
|
||||||
|
coverUploadRequestController.abort();
|
||||||
|
genealogyCreateRequestController.abort();
|
||||||
|
discardConfirmation.dispose();
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
.create-page { padding-bottom: 136rpx; }
|
@import "../../styles/adaptive-frame-profiles.scss";
|
||||||
.form-paper, .privacy-paper { margin: 28rpx; padding: 34rpx 30rpx; border: 1rpx solid $gold-soft; border-radius: 22rpx; background: $paper-white; }
|
|
||||||
.form-note { display: block; margin-top: 14rpx; color: $ink-muted; font-size: 23rpx; line-height: 1.65; }
|
.create-page {
|
||||||
.field-row { display: flex; align-items: center; min-height: 104rpx; border-bottom: 1rpx solid #eadfc9; }
|
display: flex;
|
||||||
.field-row:last-child { border-bottom: 0; }
|
min-height: 100vh;
|
||||||
.field-row text { width: 130rpx; color: $ink-muted; font-size: 28rpx; }
|
flex-direction: column;
|
||||||
.field-row input { flex: 1; color: $ink; font-size: 29rpx; }
|
background: $paper;
|
||||||
.placeholder { color: #b7aa97; }
|
}
|
||||||
.privacy-heading { display: flex; align-items: center; justify-content: space-between; }
|
.page-content {
|
||||||
.privacy-value { color: $brand-red; font-size: 24rpx; }
|
z-index: 1;
|
||||||
.bottom-action { position: fixed; right: 0; bottom: 0; left: 0; padding: 20rpx 28rpx calc(20rpx + env(safe-area-inset-bottom)); background: #fffaf0; border-top: 1rpx solid $gold-soft; }
|
padding: 24rpx 32rpx calc(72rpx + env(safe-area-inset-bottom));
|
||||||
|
}
|
||||||
|
.create-card {
|
||||||
|
@include adaptive-genealogy-state-panel;
|
||||||
|
padding: 48rpx 38rpx 42rpx;
|
||||||
|
}
|
||||||
|
.create-card__eyebrow,
|
||||||
|
.create-card__title,
|
||||||
|
.create-card__note,
|
||||||
|
.field-error,
|
||||||
|
.submit-error {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.create-card__eyebrow {
|
||||||
|
color: $brand-red;
|
||||||
|
font-size: clamp(16px, 26rpx, 20px);
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 3rpx;
|
||||||
|
}
|
||||||
|
.create-card__title {
|
||||||
|
margin-top: 12rpx;
|
||||||
|
color: $ink;
|
||||||
|
font-family: STKaiti, KaiTi, serif;
|
||||||
|
font-size: clamp(22px, 42rpx, 28px);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.create-card__note {
|
||||||
|
margin-top: 16rpx;
|
||||||
|
color: $ink-muted;
|
||||||
|
font-size: clamp(15px, 25rpx, 18px);
|
||||||
|
line-height: 1.65;
|
||||||
|
}
|
||||||
|
.field-row {
|
||||||
|
display: flex;
|
||||||
|
min-height: 100rpx;
|
||||||
|
align-items: center;
|
||||||
|
margin-top: 22rpx;
|
||||||
|
padding: 0 20rpx;
|
||||||
|
border: 1rpx solid rgba(128, 89, 49, 0.34);
|
||||||
|
border-radius: 12rpx;
|
||||||
|
background: rgba(255, 252, 245, 0.7);
|
||||||
|
}
|
||||||
|
.field-row__label {
|
||||||
|
width: 184rpx;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
color: $ink;
|
||||||
|
font-family: STKaiti, KaiTi, serif;
|
||||||
|
font-size: clamp(17px, 30rpx, 22px);
|
||||||
|
font-weight: 700;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.required-mark {
|
||||||
|
display: inline-block;
|
||||||
|
margin-right: 4rpx;
|
||||||
|
color: $brand-red;
|
||||||
|
font-size: clamp(16px, 26rpx, 20px);
|
||||||
|
line-height: 1;
|
||||||
|
transform: translateY(-3rpx);
|
||||||
|
}
|
||||||
|
.field-row input {
|
||||||
|
min-width: 0;
|
||||||
|
min-height: var(--app-touch-min);
|
||||||
|
flex: 1;
|
||||||
|
color: $ink;
|
||||||
|
font-size: clamp(16px, 28rpx, 20px);
|
||||||
|
}
|
||||||
|
.field-row--selector {
|
||||||
|
justify-content: space-between;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.region-selector-value {
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
color: $ink;
|
||||||
|
font-size: clamp(16px, 28rpx, 20px);
|
||||||
|
text-align: right;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
.region-selector-chevron {
|
||||||
|
width: 28rpx;
|
||||||
|
height: 28rpx;
|
||||||
|
margin-left: 12rpx;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
.region-selector-value--placeholder {
|
||||||
|
color: #ab9a86;
|
||||||
|
}
|
||||||
|
.placeholder {
|
||||||
|
color: #ab9a86;
|
||||||
|
}
|
||||||
|
.intro-field {
|
||||||
|
margin-top: 22rpx;
|
||||||
|
padding: 20rpx;
|
||||||
|
border: 1rpx solid rgba(128, 89, 49, 0.34);
|
||||||
|
border-radius: 12rpx;
|
||||||
|
background: rgba(255, 252, 245, 0.7);
|
||||||
|
}
|
||||||
|
.intro-field__label {
|
||||||
|
display: block;
|
||||||
|
color: $ink;
|
||||||
|
font-family: STKaiti, KaiTi, serif;
|
||||||
|
font-size: clamp(17px, 30rpx, 22px);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.intro-field textarea {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 120rpx;
|
||||||
|
margin-top: 14rpx;
|
||||||
|
color: $ink;
|
||||||
|
font-size: clamp(16px, 27rpx, 20px);
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
.owner-ancestor-field {
|
||||||
|
display: flex;
|
||||||
|
min-height: 112rpx;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 24rpx;
|
||||||
|
margin-top: 22rpx;
|
||||||
|
padding: 18rpx 20rpx;
|
||||||
|
border: 1rpx solid rgba(128, 89, 49, 0.34);
|
||||||
|
border-radius: 12rpx;
|
||||||
|
background: rgba(255, 252, 245, 0.7);
|
||||||
|
}
|
||||||
|
.owner-ancestor-field > view {
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.owner-ancestor-field__label,
|
||||||
|
.owner-ancestor-field__hint {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.owner-ancestor-field__label {
|
||||||
|
color: $ink;
|
||||||
|
font-family: STKaiti, KaiTi, serif;
|
||||||
|
font-size: clamp(17px, 30rpx, 22px);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.owner-ancestor-field__hint {
|
||||||
|
margin-top: 6rpx;
|
||||||
|
color: $ink-muted;
|
||||||
|
font-size: clamp(13px, 21rpx, 16px);
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
.cover-field {
|
||||||
|
display: grid;
|
||||||
|
gap: 12rpx;
|
||||||
|
margin-top: 22rpx;
|
||||||
|
padding: 20rpx;
|
||||||
|
border: 1rpx solid rgba(128, 89, 49, 0.34);
|
||||||
|
border-radius: 12rpx;
|
||||||
|
background: rgba(255, 252, 245, 0.7);
|
||||||
|
}
|
||||||
|
.cover-field__label {
|
||||||
|
display: block;
|
||||||
|
color: $ink;
|
||||||
|
font-family: STKaiti, KaiTi, serif;
|
||||||
|
font-size: clamp(17px, 30rpx, 22px);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.cover-field__hint {
|
||||||
|
display: block;
|
||||||
|
margin-top: 6rpx;
|
||||||
|
color: $ink-muted;
|
||||||
|
font-size: clamp(13px, 21rpx, 16px);
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
.upload-button {
|
||||||
|
justify-self: start;
|
||||||
|
min-height: 88rpx;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0 20rpx;
|
||||||
|
border: 1rpx solid rgba(159, 23, 15, 0.42);
|
||||||
|
border-radius: 8rpx;
|
||||||
|
background: transparent;
|
||||||
|
color: $brand-red;
|
||||||
|
font-size: clamp(14px, 22rpx, 17px);
|
||||||
|
}
|
||||||
|
.upload-receipt {
|
||||||
|
color: $ink-muted;
|
||||||
|
font-size: clamp(13px, 21rpx, 16px);
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
.field-error,
|
||||||
|
.submit-error {
|
||||||
|
margin-top: 10rpx;
|
||||||
|
color: $brand-red;
|
||||||
|
font-size: clamp(15px, 24rpx, 18px);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
.access-rule {
|
||||||
|
margin-top: 28rpx;
|
||||||
|
}
|
||||||
|
.access-rule__label {
|
||||||
|
color: $ink;
|
||||||
|
font-family: STKaiti, KaiTi, serif;
|
||||||
|
font-size: clamp(17px, 31rpx, 22px);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.access-rule__options {
|
||||||
|
display: flex;
|
||||||
|
margin-top: 16rpx;
|
||||||
|
gap: 16rpx;
|
||||||
|
}
|
||||||
|
.access-rule__option {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
min-height: 88rpx;
|
||||||
|
box-sizing: border-box;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 20rpx 12rpx;
|
||||||
|
border: 1rpx solid rgba(128, 89, 49, 0.34);
|
||||||
|
border-radius: 12rpx;
|
||||||
|
color: $ink-muted;
|
||||||
|
font-size: clamp(15px, 25rpx, 18px);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.access-rule__option--active {
|
||||||
|
border-color: $brand-red;
|
||||||
|
background: rgba(159, 23, 15, 0.07);
|
||||||
|
color: $brand-red;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.create-card .app-button {
|
||||||
|
margin-top: 32rpx;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||