This commit is contained in:
rain
2026-07-24 18:03:02 +08:00
parent c7f278fe79
commit ec8486b035
86 changed files with 7943 additions and 1841 deletions
+19 -89
View File
@@ -1,19 +1,13 @@
<!-- 页面编号F-01用途家族动态入口列表 DTO 缺失时不展示 fixture 条目 -->
<template>
<view class="family-page" :class="`feed-state--${feedState}`">
<ModulePageBackground module="family" />
<view class="family-page__header"><PageHeader root title="家族动态" :action="hasValidContext ? '发布' : ''" @action="toPublish" /></view>
<view class="feed-content">
<view class="feed-heading"><text>家族圈</text><text>家宴通知与共同记忆</text></view>
<template v-if="hasValidContext">
<view class="feed-source-note"><text>动态列表接口当前没有声明条目 DTO页面不猜测标题正文发布人或时间已停止展示本地动态</text></view>
<view class="feed-shortcuts">
<view v-for="item in shortcuts" :key="item.key" class="feed-shortcut" @click="openSection(item.key)"><text>{{ item.label }}</text></view>
</view>
</template>
<view class="feed-state-card">
<view class="feed-state-card__copy"><text>{{ stateCopy.title }}</text><text>{{ stateCopy.copy }}</text></view>
</view>
<view v-if="hasValidContext" class="feed-shortcuts"><view v-for="item in shortcuts" :key="item.key" class="feed-shortcut" @click="openSection(item.key)"><text>{{ item.label }}</text></view></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">{{ item.content }}</text><text class="feed-card__meta">{{ feedTypeLabel(item.type) }} · {{ item.time }}</text></view></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>
<AppTabbar active="family" />
@@ -22,93 +16,29 @@
<script setup>
import { computed, ref } from "vue";
import { onLoad } from "@dcloudio/uni-app";
import { onLoad, onShow } from "@dcloudio/uni-app";
import AppLoading from "@/components/AppLoading.vue";
import AppTabbar from "@/components/AppTabbar.vue";
import ModulePageBackground from "@/components/ModulePageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
import { appApi, isRequestCancelled } from "@/utils/api.js";
import { genealogyContext } from "@/utils/genealogy-context.js";
import { goRoot, openPage } from "@/utils/navigation.js";
const genealogyId = ref("");
const hasValidContext = ref(false);
const feedState = ref("unavailable");
const stateCopy = computed(() => {
if (!hasValidContext.value) {
return {
title: "请先选择有效家谱",
copy: "家族动态必须归属明确家谱,页面不会展示其他家谱的内容。",
action: "返回我的家谱",
};
}
return {
title: "动态列表待后端字段合同",
copy: "已找到动态列表 operation,但响应只声明通用对象数组;待后端提供动态 ID、标题、正文、发布人和时间字段后再恢复列表与详情入口。",
action: "发布家族动态",
};
});
const shortcuts = [
{ key: "articles", label: "谱文" },
{ key: "albums", label: "相册" },
{ key: "rituals", label: "礼仪" },
{ key: "memos", label: "备忘" },
{ key: "people", label: "人物录" },
{ key: "gifts", label: "贺礼簿" },
{ key: "merits", label: "功德录" },
{ key: "videos", label: "家族视频" },
];
onLoad((query) => {
const hasRouteIdentity = Object.prototype.hasOwnProperty.call(query || {}, "genealogyId");
const resolvedGenealogyId = hasRouteIdentity
? String(query?.genealogyId || "")
: String(genealogyContext.getCurrentGenealogyId() || "");
genealogyId.value = resolvedGenealogyId;
hasValidContext.value = /^[1-9]\d*$/.test(resolvedGenealogyId);
feedState.value = hasValidContext.value ? "unavailable" : "error";
if (!hasRouteIdentity && hasValidContext.value) {
goRoot("F01", { genealogyId: resolvedGenealogyId }).catch(() => {
hasValidContext.value = false;
feedState.value = "error";
});
}
});
const toPublish = () => hasValidContext.value
? openPage("F02", { genealogyId: genealogyId.value }, "F01")
: goRoot("G01");
const openSection = (key) => {
const routes = {
articles: "F04",
albums: "F07",
rituals: "R05",
memos: "R10",
people: "R01",
gifts: "R03",
merits: "R11",
videos: "F10",
};
return openPage(routes[key], { genealogyId: genealogyId.value }, "F01");
};
const handlePrimaryAction = () => hasValidContext.value ? toPublish() : goRoot("G01");
const genealogyId = ref(""); const hasValidContext = ref(false); const feedState = ref("loading"); const feeds = ref([]); let active = true;
const feedTypeLabel = (type) => String(type || "").trim().toLowerCase() === "text" ? "文字动态" : "家族动态";
const shortcuts = [{ key: "articles", label: "谱文" }, { key: "albums", label: "相册" }, { key: "rituals", label: "礼仪" }, { key: "memos", label: "备忘" }, { key: "people", label: "人物录" }, { key: "gifts", label: "贺礼簿" }, { key: "merits", label: "功德录" }, { key: "videos", label: "家族视频" }];
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"; try { const rows = await appApi.getFeeds(genealogyId.value); if (!active) return; feeds.value = rows; feedState.value = feeds.value.length ? "list" : "empty"; } catch (error) { if (!active || isRequestCancelled(error)) return; feedState.value = "error"; } };
onLoad((query) => { const supplied = Object.prototype.hasOwnProperty.call(query || {}, "genealogyId"); genealogyId.value = supplied ? String(query.genealogyId || "") : String(genealogyContext.getCurrentGenealogyId() || ""); hasValidContext.value = /^[1-9]\d*$/.test(genealogyId.value); if (hasValidContext.value) void loadFeeds(); else feedState.value = "error"; });
onShow(() => { if (hasValidContext.value) void loadFeeds(); });
const toPublish = () => hasValidContext.value ? openPage("F02", { genealogyId: genealogyId.value }, "F01") : goRoot("G01");
const openFeed = (item) => openPage("F03", { genealogyId: genealogyId.value, feedId: item.id }, "F01");
const openSection = (key) => { const routes = { articles: "F04", albums: "F07", rituals: "R05", memos: "R10", people: "R01", gifts: "R03", merits: "R11", videos: "F10" }; return openPage(routes[key], { genealogyId: genealogyId.value }, "F01"); };
const handlePrimaryAction = () => feedState.value === "error" ? loadFeeds() : toPublish();
</script>
<style scoped lang="scss">
@use "../../styles/adaptive-frame-profiles.scss" as adaptive;
.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 190rpx; }
.feed-heading text { display: block; }
.feed-heading text:first-child { color: $ink; font-family: STKaiti, KaiTi, serif; font-size: 31rpx; font-weight: 700; }
.feed-heading text:last-child { margin-top: 6rpx; color: $ink-muted; font-size: 23rpx; }
.feed-source-note { margin-top: 10rpx; padding: 12rpx 16rpx; border: 1rpx solid rgba(128, 106, 81, .22); border-radius: 10rpx; background: rgba(255, 255, 255, .56); }
.feed-source-note text { display: block; color: $ink-muted; font-size: 20rpx; line-height: 1.5; }
.feed-shortcuts { @include adaptive.adaptive-scroll-button(secondary); display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); width: 100%; min-height: 48px; margin-top: 15rpx; }
.feed-shortcut { width: 100%; height: 48px; min-height: 44px; }
.feed-shortcut text { display: flex; align-items: center; justify-content: center; height: 100%; color: $ink; font-size: 22rpx; font-weight: 700; }
.feed-state-card { @include adaptive.adaptive-family-letter; display: flex; width: 100%; min-height: 230rpx; flex-direction: column; justify-content: center; margin-top: 70rpx; box-sizing: border-box; }
.feed-state-card__copy { padding: 23% 10%; box-sizing: border-box; text-align: center; }
.feed-state-card text { display: block; }
.feed-state-card text:first-child { color: $ink; font-family: STKaiti, KaiTi, serif; font-size: 31rpx; font-weight: 700; }
.feed-state-card text:last-child { margin-top: 13rpx; color: $ink-muted; font-size: 23rpx; line-height: 1.45; }
.feed-action { @include adaptive.adaptive-scroll-button(primary); width: 514rpx; max-width: 100%; min-height: 76rpx; margin: 19rpx auto 0; }
.feed-action text { display: flex; align-items: center; justify-content: center; height: 100%; color: #fff9ed; font-size: 24rpx; font-weight: 700; }
.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 190rpx}.feed-heading text{display:block}.feed-heading text:first-child{color:$ink;font-family:STKaiti,KaiTi,serif;font-size:31rpx;font-weight:700}.feed-heading text:last-child{margin-top:6rpx;color:$ink-muted;font-size:23rpx}.feed-shortcuts{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));margin-top:15rpx}.feed-shortcut{min-height:48px;display:flex;align-items:center;justify-content:center}.feed-shortcut text{color:$ink;font-size:22rpx;font-weight:700}.feed-list{margin-top:22rpx}.feed-card,.feed-state-card{@include adaptive.adaptive-family-letter;box-sizing:border-box;margin-top:16rpx;padding:30rpx}.feed-card text,.feed-state-card text{display:block}.feed-card__publisher{color:$brand-red;font-size:22rpx}.feed-card__content{margin-top:12rpx;color:$ink;font-size:28rpx;line-height:1.55}.feed-card__meta{margin-top:12rpx;color:$ink-muted;font-size:21rpx}.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:31rpx;font-weight:700}.feed-state-card text:last-child{margin-top:13rpx;color:$ink-muted;font-size:23rpx;line-height:1.45}.feed-action{@include adaptive.adaptive-scroll-button(primary);width:514rpx;max-width:100%;min-height:76rpx;margin:19rpx auto}.feed-action text{display:flex;height:100%;align-items:center;justify-content:center;color:#fff9ed;font-size:24rpx;font-weight:700}
</style>
+76 -18
View File
@@ -1,4 +1,4 @@
<!-- 页面编号F-02用途 Apifox 已声明 feedContent 发布家族动态 -->
<!-- 页面编号F-02用途 Apifox 完整 AppFamilyFeedBody 创建家族动态 -->
<template>
<view class="publish-page" :class="`publish-state--${publishState}`">
<ModulePageBackground module="family" />
@@ -6,10 +6,31 @@
<view class="publish-panel">
<view v-if="publishState === 'form'" class="publish-form">
<text>记录此刻</text>
<text>当前接口只明确文本动态的 `feedContent` 请求字段媒体排序和状态均不由本页猜测或提交</text>
<view class="publish-field"><textarea v-model="content" auto-height maxlength="300" placeholder="写下想对家人说的话" placeholder-class="publish-placeholder" @input="submitError = ''" /></view>
<text>填写需要的内容留空的字段由服务端按接口默认行为处理</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>
<input v-model="form.feedType" placeholder="留空时服务端默认为 text" placeholder-class="publish-placeholder" @input="submitError = ''" />
</view>
<view class="publish-field publish-field--media">
<view>
<text class="publish-field__label">动态配图</text>
<text class="publish-field__hint">图片选定后会先取得真实上传回执并在提交动态时关联</text>
</view>
<button class="upload-button" :disabled="isUploading || isSubmitting" @click="uploadImage">{{ isUploading ? '上传中' : '添加图片' }}</button>
<text v-for="(receipt, index) in mediaReceipts" :key="`${receipt.ossId}-${index}`" class="upload-receipt">已上传{{ receipt.fileName || '图片' }}</text>
<text v-if="uploadError" class="publish-error">{{ uploadError }}</text>
</view>
<view class="publish-field">
<text class="publish-field__label">排序值</text>
<input v-model="form.sortOrder" type="number" placeholder="留空时服务端默认为 0" placeholder-class="publish-placeholder" @input="submitError = ''" />
</view>
<text v-if="submitError" class="publish-error">{{ submitError }}</text>
<AppButton block :label="isSubmitting ? '正在提交' : '提交动态'" :disabled="isSubmitting" @click="submit" />
<AppButton block :label="isSubmitting ? '正在提交' : '提交动态'" :disabled="isSubmitting || isUploading" @click="submit" />
</view>
<view v-else class="publish-result">
<text>{{ resultCopy.title }}</text>
@@ -32,7 +53,7 @@
</template>
<script setup>
import { computed, onUnmounted, ref } from "vue";
import { computed, onUnmounted, reactive, ref } from "vue";
import { onBackPress, onLoad } from "@dcloudio/uni-app";
import AppButton from "@/components/AppButton.vue";
import AppDialog from "@/components/AppDialog.vue";
@@ -40,26 +61,33 @@ import ModulePageBackground from "@/components/ModulePageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
import { appApi, createRequestController, isRequestCancelled } from "@/utils/api.js";
import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
import { isImagePickCancelled, pickAndUploadImage } from "@/utils/resumable-image-upload.js";
import { goBack, handleBackPress, returnTo, runBackGuard } from "@/utils/navigation.js";
const genealogyId = ref("");
const content = ref("");
const form = reactive({ feedContent: "", feedType: "", sortOrder: "" });
const mediaReceipts = ref([]);
const publishState = ref("form");
const isSubmitting = ref(false);
const isUploading = ref(false);
const submitError = ref("");
const uploadError = ref("");
const discardVisible = ref(false);
const requestController = createRequestController();
const isDirty = computed(() => Boolean(content.value.trim()));
const mediaOssIds = computed(() => mediaReceipts.value.map((item) => item.ossId).join(","));
const isDirty = computed(() => Boolean(
form.feedContent.trim() || form.feedType.trim() || form.sortOrder.trim() || mediaReceipts.value.length,
));
const hasValidContext = computed(() => /^[1-9]\d*$/.test(genealogyId.value));
const resultCopy = computed(() => ({
success: {
title: "动态已提交服务端",
copy: "服务端已返回成功信封。动态列表仍缺条目 DTO,返回后不会把本地内容伪装成列表项。",
copy: "服务端已返回成功信封。返回动态列表后将重新读取服务端数据。",
action: "返回家族动态",
},
error: {
title: "动态未提交",
copy: "当前文字仍保留在页面中,可返回表单继续核对。",
copy: "当前内容仍保留在页面中,可返回表单继续核对。",
action: "返回填写",
},
invalid: {
@@ -79,18 +107,40 @@ onLoad((query) => {
genealogyId.value = String(query?.genealogyId || "");
if (!hasValidContext.value) publishState.value = "invalid";
});
const uploadImage = async () => {
if (isUploading.value || isSubmitting.value) return;
isUploading.value = true;
uploadError.value = "";
try {
const receipt = await pickAndUploadImage({ requestController });
mediaReceipts.value = [...mediaReceipts.value, receipt];
} catch (error) {
if (!isImagePickCancelled(error) && !isRequestCancelled(error)) {
uploadError.value = error?.message || "图片上传失败,请稍后重试。";
}
} finally {
isUploading.value = false;
}
};
const submit = async () => {
if (isSubmitting.value || !hasValidContext.value) return;
const feedContent = content.value.trim();
if (!feedContent) {
submitError.value = "请先写下动态内容";
if (isSubmitting.value || isUploading.value || !hasValidContext.value) return;
if (!form.feedContent.trim()) {
submitError.value = "请填写动态内容";
return;
}
isSubmitting.value = true;
submitError.value = "";
try {
await appApi.createFeed(genealogyId.value, { feedContent }, { requestController });
content.value = "";
await appApi.createFeed(genealogyId.value, {
feedContent: form.feedContent,
feedType: form.feedType,
mediaOssIds: mediaOssIds.value,
sortOrder: form.sortOrder,
}, { requestController });
Object.assign(form, { feedContent: "", feedType: "", sortOrder: "" });
mediaReceipts.value = [];
publishState.value = "success";
} catch (error) {
if (isRequestCancelled(error)) return;
@@ -103,7 +153,7 @@ const submit = async () => {
const requestBack = () => runBackGuard({
transientOpen: discardVisible.value,
dirty: isDirty.value,
submitting: isSubmitting.value,
submitting: isSubmitting.value || isUploading.value,
"close-transient": cancelDiscard,
"block-submitting": () => true,
"confirm-discard": requestDiscardConfirmation,
@@ -136,8 +186,16 @@ onUnmounted(() => {
.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: 36rpx; font-weight: 700; }
.publish-form > text:nth-child(2), .publish-result > text:nth-child(2) { margin-top: 12rpx; color: $ink-muted; font-size: 23rpx; line-height: 1.6; }
.publish-field { @include adaptive.adaptive-family-field; display: flex; min-height: 250rpx; margin-top: 24rpx; padding: 25rpx; box-sizing: border-box; }
.publish-field textarea { width: 100%; min-height: 200rpx; color: $ink; font-size: 24rpx; line-height: 1.7; }
.publish-field { @include adaptive.adaptive-family-field; margin-top: 20rpx; padding: 18rpx 22rpx; }
.publish-field__label { display: block; color: $ink; font-size: 25rpx; font-weight: 700; }
.required-mark { display: inline-block; margin-right: 4rpx; color: $brand-red; font-size: 26rpx; 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: 24rpx; }
.publish-field input { min-height: 64rpx; }
.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: 20rpx; line-height: 1.45; }
.upload-button { justify-self: start; min-height: 60rpx; margin: 0; padding: 0 20rpx; border: 1rpx solid rgba(159, 23, 15, .42); border-radius: 8rpx; background: transparent; color: $brand-red; font-size: 22rpx; }
.upload-receipt { color: $ink-muted; font-size: 21rpx; overflow-wrap: anywhere; }
.publish-placeholder { color: #8e806e; }
.publish-error { margin-top: 12rpx; color: $brand-red; font-size: 22rpx; line-height: 1.5; }
.publish-form > .app-button { margin-top: 24rpx; }
+164 -170
View File
@@ -1,232 +1,226 @@
<!-- 页面编号F-03用途动态详情与一级评论动态正文 DTO 未声明时不展示伪字段 -->
<!-- 页面编号F-03用途家族动态详情与一级评论 -->
<template>
<view class="feed-detail-page" :class="{ 'feed-state--expired': feedState === 'expired', 'feed-state--error': feedState === 'error', 'feed-state--ready': feedState === 'ready' }">
<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 v-if="feedState === 'loading'" class="feed-detail-loading">
<AppLoading text="正在读取家人评论" description="动态正文的展示字段尚未由后端合同声明。" />
</view>
<view class="feed-detail-content">
<AppLoading v-if="feedState === 'loading'" text="正在读取动态" description="请稍候,正在同步家族动态与评论。" />
<view v-else class="feed-detail-content">
<template v-if="feedState === 'ready'">
<view class="feed-source-note"><text>动态详情接口尚未声明标题正文发布人和发布时间字段本页不猜测这些字段只展示该动态的真实一级评论</text></view>
<view class="feed-article-card">
<text class="feed-article-card__title">动态正文待后端字段合同</text>
<text class="feed-article-card__body">已根据当前动态 ID 读取评论正文内容会在详情响应提供可消费 DTO 后再接入</text>
</view>
<view class="feed-comments-panel">
<view class="feed-comments-heading">
<text>家人评论</text><text>{{ feedComments.length }} </text>
<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>{{ feed.time || '未标注时间' }}</text>
</view>
<view v-for="comment in feedComments" :key="comment.id" class="feed-comment-card">
<view><text>{{ comment.author }}</text><text>{{ comment.time }}</text></view>
<text>{{ comment.content }}</text>
<text v-if="comment.replyCount > 0" class="feed-comment-card__replies">直属回复 {{ comment.replyCount }} </text>
</view>
<view v-if="!feedComments.length" class="feed-comments-empty">
<text>还没有一级评论</text><text>服务端返回评论后会显示在这里</text>
<text class="feed-card__content">{{ feed.content }}</text>
<view class="feed-card__meta">
<text>发布{{ feed.publisher }}</text>
<text>{{ feed.likeCount }} 次点赞 · {{ feed.commentCount }} 条评论</text>
</view>
</view>
<view class="feed-comment-form" :class="{ 'comment-state--submitting': commentState === 'submitting', 'comment-state--error': commentState === 'error' }">
<text>提交一级评论</text>
<textarea v-model="commentDraft" auto-height maxlength="1000" placeholder="对家人说点什么" />
<text v-if="commentError" class="feed-comment-error">{{ commentError }}</text>
<text v-else-if="commentNotice" class="feed-comment-notice">{{ commentNotice }}</text>
<AppButton block :disabled="commentState === 'submitting'" :label="commentState === 'submitting' ? '正在提交' : '提交评论'" @click="submitComment" />
<view class="comment-section">
<text class="section-title">评论</text>
<AppLoading v-if="commentState === 'loading'" text="正在读取评论" />
<view v-else-if="commentState === 'list'" class="comment-list">
<view v-for="item in comments" :key="item.id" class="comment-card">
<view class="comment-card__heading">
<text>{{ item.author }}</text>
<text>{{ item.time || '刚刚' }}</text>
</view>
<text class="comment-card__content">{{ item.content }}</text>
</view>
</view>
<text v-else class="comment-state-copy">{{ commentStateCopy }}</text>
<view class="comment-editor">
<textarea
v-model="commentDraft"
auto-height
maxlength="1000"
placeholder="写下你的评论"
placeholder-class="comment-editor__placeholder"
@input="commentError = ''"
/>
<text v-if="commentError" class="comment-error">{{ commentError }}</text>
<AppButton
block
:disabled="isSubmittingComment"
:label="isSubmittingComment ? '正在提交' : '发表评论'"
@click="submitComment"
/>
</view>
</view>
</template>
</view>
<view v-else class="feed-state-card">
<text>{{ stateCopy.title }}</text>
<text>{{ stateCopy.copy }}</text>
<AppButton :type="feedState === 'error' ? 'secondary' : 'primary'" block :label="stateCopy.action" @click="handleStateAction" />
<AppButton type="secondary" block :label="stateCopy.action" @click="handleStateAction" />
</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, onUnmounted, ref } from "vue";
import { onBackPress, onLoad } from "@dcloudio/uni-app";
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 { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
import { appApi, createRequestController, isRequestCancelled } from "@/utils/api.js";
import {
goBack,
handleBackPress,
returnTo,
runBackGuard,
} from "@/utils/navigation.js";
import { goBack, handleBackPress, returnTo } from "@/utils/navigation.js";
const genealogyId = ref("");
const feedId = ref("");
const feedState = ref("loading");
const commentState = ref("idle");
const commentState = ref("loading");
const feed = ref(null);
const comments = ref([]);
const commentDraft = ref("");
const commentError = ref("");
const commentNotice = ref("");
const discardVisible = ref(false);
const feedComments = ref([]);
const feedRequestController = createRequestController();
let loadSequence = 0;
const isDirty = computed(() => Boolean(commentDraft.value.trim()));
const isSubmittingComment = ref(false);
const controller = createRequestController();
let pageActive = 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 stateCopy = computed(() => {
if (feedState.value === "error") {
if (!hasValidContext.value) {
return {
title: "评论暂不可用",
copy: "评论读取没有得到可消费的服务端响应;页面不会用本地数据替代。",
action: "重新读取",
title: "动态入口无效",
copy: "没有取得当前家谱和动态标识,页面不会展示其他动态。",
action: "返回上一页",
};
}
if (feedState.value === "missing") {
return {
title: "该动态已不存在",
copy: "它可能已被发布者删除,或当前账号已不再拥有查看权限。",
action: "返回家族动态",
};
}
return {
title: "动态入口无效",
copy: "没有取得当前家谱和动态标识,页面不会回退到其他动态。",
action: genealogyId.value ? "返回家族动态" : "返回上一页",
title: "动态暂时无法读取",
copy: "请检查网络后重新读取;本页不会替换为其他动态。",
action: "重新读取",
};
});
const discardConfirmation = createDiscardConfirmation((visible) => {
discardVisible.value = visible;
const commentStateCopy = computed(() => {
if (commentState.value === "empty") return "还没有评论,欢迎留下第一句话。";
return "评论暂时无法读取,稍后可重新进入本页查看。";
});
const requestDiscardConfirmation = discardConfirmation.request;
const confirmDiscard = discardConfirmation.confirm;
const cancelDiscard = discardConfirmation.cancel;
const loadComments = async () => {
if (!genealogyId.value || !feedId.value) {
feedState.value = "expired";
return false;
const loadFeed = async () => {
if (!hasValidContext.value) {
feedState.value = "invalid";
return;
}
const sequence = ++loadSequence;
feedState.value = "loading";
try {
const comments = await appApi.getFeedComments(genealogyId.value, feedId.value, {
requestController: feedRequestController,
});
if (sequence !== loadSequence) return false;
feedComments.value = comments;
const current = await appApi.getFeedDetail(genealogyId.value, feedId.value, { requestController: controller });
if (!pageActive) return;
feed.value = current;
feedState.value = "ready";
return true;
} catch (error) {
if (isRequestCancelled(error) || sequence !== loadSequence) return false;
feedState.value = "error";
return false;
if (!pageActive || isRequestCancelled(error)) return;
feedState.value = error?.code === "HTTP_ERROR" && error?.httpStatus === 404 ? "missing" : "error";
}
};
const loadComments = async () => {
if (!hasValidContext.value || feedState.value !== "ready") return;
commentState.value = "loading";
try {
const rows = await appApi.getFeedComments(genealogyId.value, feedId.value, { requestController: controller });
if (!pageActive) return;
comments.value = rows;
commentState.value = rows.length ? "list" : "empty";
} catch (error) {
if (!pageActive || isRequestCancelled(error)) return;
commentState.value = "error";
}
};
const refresh = async () => {
await loadFeed();
if (feedState.value === "ready") await loadComments();
};
const submitComment = async () => {
if (isSubmittingComment.value || !hasValidContext.value) return;
const commentContent = commentDraft.value.trim();
if (!commentContent) {
commentError.value = "请填写评论内容";
return;
}
isSubmittingComment.value = true;
commentError.value = "";
try {
await appApi.createFeedComment(genealogyId.value, feedId.value, { commentContent }, { requestController: controller });
if (!pageActive) return;
commentDraft.value = "";
await refresh();
} catch (error) {
if (!pageActive || isRequestCancelled(error)) return;
commentError.value = error?.message || "评论提交失败,请稍后重试";
} finally {
if (pageActive) isSubmittingComment.value = false;
}
};
onLoad((query) => {
genealogyId.value = String(query.genealogyId || "");
feedId.value = String(query.feedId || "");
loadComments();
genealogyId.value = String(query?.genealogyId || "");
feedId.value = String(query?.feedId || "");
void refresh();
});
onShow(() => {
if (hasValidContext.value) void refresh();
});
onUnload(() => {
pageActive = false;
controller.abort();
});
const submitComment = async () => {
if (commentState.value === "submitting" || feedState.value !== "ready") return;
const content = commentDraft.value.trim();
if (!content) {
commentError.value = "请先写下评论内容";
commentNotice.value = "";
return;
}
commentError.value = "";
commentNotice.value = "";
commentState.value = "submitting";
try {
await appApi.createFeedComment(genealogyId.value, feedId.value, { commentContent: content }, {
requestController: feedRequestController,
});
commentDraft.value = "";
const refreshed = await loadComments();
if (!refreshed) {
commentState.value = "error";
commentError.value = "评论已提交,但列表刷新失败;请稍后重新查看。";
return;
}
commentState.value = "idle";
commentNotice.value = "评论已提交,并已从服务端刷新。";
} catch (error) {
if (isRequestCancelled(error)) return;
commentState.value = "error";
commentError.value = error?.message || "评论提交失败,请稍后重试。";
}
};
const requestBack = () =>
runBackGuard({
transientOpen: discardVisible.value,
dirty: isDirty.value,
submitting: commentState.value === "submitting",
"close-transient": cancelDiscard,
"block-submitting": () => true,
"confirm-discard": requestDiscardConfirmation,
});
const backToFamily = async () => {
if (!genealogyId.value) return goBack();
const confirmed = isDirty.value ? await requestDiscardConfirmation() : true;
if (!confirmed) return false;
return returnTo("F01", { genealogyId: genealogyId.value });
};
const handleStateAction = () => {
if (feedState.value === "error") return loadComments();
return backToFamily();
};
const backToFamily = () => hasValidContext.value
? returnTo("F01", { genealogyId: genealogyId.value })
: goBack();
const requestBack = () => backToFamily();
const handleStateAction = () => feedState.value === "error" ? refresh() : backToFamily();
onBackPress((event) => handleBackPress(event, requestBack));
onUnmounted(() => {
++loadSequence;
feedRequestController.abort();
discardConfirmation.dispose();
});
</script>
<style scoped lang="scss">
@use "../../styles/adaptive-frame-profiles.scss" as adaptive;
.feed-detail-page { display: flex; min-height: 100vh; flex-direction: column; background: $paper; }
.feed-detail-header, .feed-detail-loading, .feed-detail-content { z-index: 1; }
.feed-detail-loading { min-height: calc(100vh - 100rpx); }
.feed-detail-content { padding: 18rpx 24rpx 72rpx; }
.feed-source-note { margin-top: 12rpx; padding: 14rpx 18rpx; border: 1rpx solid rgba(128, 106, 81, .22); border-radius: 10rpx; background: rgba(255,255,255,.56); }
.feed-source-note text { display: block; color: $ink-muted; font-size: 20rpx; line-height: 1.5; }
.feed-article-card, .feed-comments-panel, .feed-comment-form, .feed-state-card { @include adaptive.adaptive-family-content; width: 100%; }
.feed-article-card { padding: 42rpx 48rpx; }
.feed-article-card text, .feed-state-card > text { display: block; }
.feed-article-card__title { color: $ink; font-family: STKaiti, KaiTi, serif; font-size: 32rpx; font-weight: 700; }
.feed-article-card__body { margin-top: 18rpx; color: $ink-muted; font-size: 24rpx; line-height: 1.7; }
.feed-comments-panel { margin-top: 18rpx; padding: 38rpx 38rpx 34rpx; }
.feed-comments-heading { display: flex; align-items: center; justify-content: space-between; color: $brand-red; font-size: 24rpx; font-weight: 700; }
.feed-comments-heading text:last-child { color: $ink-muted; font-size: 21rpx; font-weight: 400; }
.feed-comment-card { @include adaptive.adaptive-family-field; margin-top: 16rpx; padding: 20rpx 22rpx; }
.feed-comment-card > view { display: flex; justify-content: space-between; gap: 18rpx; color: $ink-muted; font-size: 20rpx; }
.feed-comment-card > text { display: block; margin-top: 9rpx; color: $ink; font-size: 24rpx; line-height: 1.55; }
.feed-comment-card > .feed-comment-card__replies { color: $ink-muted; font-size: 20rpx; }
.feed-comments-empty { padding: 34rpx 10rpx 20rpx; text-align: center; }
.feed-comments-empty text { display: block; color: $ink-muted; font-size: 23rpx; line-height: 1.6; }
.feed-comments-empty text:first-child { color: $ink; font-size: 28rpx; font-weight: 700; }
.feed-comment-form { margin-top: 18rpx; padding: 38rpx 40rpx 42rpx; }
.feed-comment-form > text:first-child { color: $ink; font-family: STKaiti, KaiTi, serif; font-size: 30rpx; font-weight: 700; }
.feed-comment-form textarea { @include adaptive.adaptive-family-field; width: auto; min-width: 0; min-height: 126rpx; margin-top: 16rpx; padding: 20rpx 22rpx; color: $ink; font-size: 24rpx; line-height: 1.55; }
.feed-comment-error, .feed-comment-notice { display: block; margin-top: 10rpx; font-size: 22rpx; }
.feed-comment-error { color: $brand-red; }
.feed-comment-notice { color: $ink-muted; }
.feed-comment-form .app-button { margin-top: 22rpx; }
.feed-detail-header, .feed-detail-content { z-index: 1; }
.feed-detail-content { flex: 1; padding: 18rpx 24rpx 72rpx; }
.feed-detail-body { margin-top: 18rpx; }
.feed-card, .comment-section, .feed-state-card { @include adaptive.adaptive-family-content; box-sizing: border-box; }
.feed-card { padding: 30rpx; }
.feed-card__heading, .feed-card__meta, .comment-card__heading { display: flex; justify-content: space-between; gap: 18rpx; }
.feed-card__heading text:first-child, .comment-card__heading text:first-child { color: $brand-red; font-size: 23rpx; font-weight: 700; }
.feed-card__heading text:last-child, .comment-card__heading text:last-child { color: $ink-muted; font-size: 21rpx; text-align: right; }
.feed-card__content { display: block; margin-top: 20rpx; color: $ink; font-size: 29rpx; line-height: 1.7; white-space: pre-wrap; }
.feed-card__meta { margin-top: 22rpx; color: $ink-muted; font-size: 21rpx; }
.comment-section { margin-top: 18rpx; padding: 28rpx; }
.section-title { display: block; color: $ink; font-family: STKaiti, KaiTi, serif; font-size: 32rpx; font-weight: 700; }
.comment-list { margin-top: 18rpx; }
.comment-card { padding: 18rpx 0; border-bottom: 1rpx solid rgba(128, 89, 49, .16); }
.comment-card__content { display: block; margin-top: 10rpx; color: $ink; font-size: 25rpx; line-height: 1.55; white-space: pre-wrap; }
.comment-state-copy { display: block; margin-top: 18rpx; color: $ink-muted; font-size: 24rpx; line-height: 1.5; }
.comment-editor { margin-top: 24rpx; padding-top: 22rpx; border-top: 1rpx solid rgba(128, 89, 49, .18); }
.comment-editor textarea { display: block; box-sizing: border-box; width: 100%; min-height: 130rpx; padding: 18rpx; border: 1rpx solid rgba(128, 89, 49, .3); border-radius: 12rpx; color: $ink; font-size: 25rpx; 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: 22rpx; }
.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: 35rpx; font-weight: 700; }
.feed-state-card > text:nth-child(2) { margin-top: 18rpx; color: $ink-muted; font-size: 24rpx; line-height: 1.65; }
.feed-state-card .app-button { margin-top: 30rpx; }
+45 -9
View File
@@ -1,10 +1,17 @@
<!-- 页面编号F-04用途谱文入口列表 item DTO 未声明时不展示本地文章 -->
<!-- 页面编号F-04用途读取并展示当前家谱的谱文列表 -->
<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 class="article-list-state-card">
<view v-if="listState === 'list'" class="article-list-items">
<view v-for="item in articles" :key="item.id" class="article-card" @click="openArticle(item)">
<text class="article-card__title">{{ item.title }}</text>
<text class="article-card__summary">{{ item.summary || item.content }}</text>
<text class="article-card__meta">{{ item.author || '家族成员' }} · {{ item.time || '未标注时间' }}</text>
</view>
</view>
<view v-else class="article-list-state-card">
<text>{{ stateCopy.title }}</text>
<text>{{ stateCopy.copy }}</text>
<AppButton block :label="stateCopy.action" @click="handleStateAction" />
@@ -15,20 +22,30 @@
<script setup>
import { computed, ref } from "vue";
import { onLoad } from "@dcloudio/uni-app";
import { onLoad, onShow } from "@dcloudio/uni-app";
import AppButton from "@/components/AppButton.vue";
import ModulePageBackground from "@/components/ModulePageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
import { appApi } from "@/utils/api.js";
import { goBack, openPage } from "@/utils/navigation.js";
const genealogyId = ref("");
const hasValidContext = ref(false);
const listState = ref("unavailable");
const listState = ref("loading");
const articles = ref([]);
const stateCopy = computed(() => hasValidContext.value
? {
title: "谱文列表待后端字段合同",
copy: "列表接口只声明通用对象数组,没有文章 ID、分类、标题、摘要、作者或更新时间字段;页面已停止展示本地文章和本地筛选。",
? listState.value === "empty" ? {
title: "还没有谱文",
copy: "新建第一篇谱文,记录值得传承的故事。",
action: "新建谱文",
} : listState.value === "error" ? {
title: "谱文暂时无法读取",
copy: "请检查网络后重新读取。",
action: "重新读取",
} : {
title: "正在读取谱文",
copy: "请稍候。",
action: "重新读取",
}
: {
title: "谱文入口无效",
@@ -39,12 +56,25 @@ const stateCopy = computed(() => hasValidContext.value
onLoad((query) => {
genealogyId.value = String(query?.genealogyId || "");
hasValidContext.value = /^[1-9]\d*$/.test(genealogyId.value);
listState.value = hasValidContext.value ? "unavailable" : "invalid";
if (hasValidContext.value) void loadArticles(); else listState.value = "invalid";
});
onShow(() => { if (hasValidContext.value) void loadArticles(); });
const loadArticles = async () => {
listState.value = "loading";
try {
const rows = await appApi.getArticles(genealogyId.value);
articles.value = rows;
listState.value = articles.value.length ? "list" : "empty";
} catch { listState.value = "error"; }
};
const createArticle = () => hasValidContext.value
? openPage("F06", { genealogyId: genealogyId.value, mode: "create" }, "F04")
: Promise.resolve(false);
const handleStateAction = () => hasValidContext.value ? createArticle() : goBack();
const openArticle = (item) => openPage("F05", { genealogyId: genealogyId.value, articleId: item.id }, "F04");
const handleStateAction = () => {
if (!hasValidContext.value) return goBack();
return listState.value === "error" ? loadArticles() : createArticle();
};
</script>
<style scoped lang="scss">
@@ -52,6 +82,12 @@ const handleStateAction = () => hasValidContext.value ? createArticle() : goBack
.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 72rpx; }
.article-list-items { margin-top: 24rpx; }
.article-card { @include adaptive.adaptive-family-content; display: flex; min-height: 150rpx; flex-direction: column; justify-content: center; box-sizing: border-box; margin-bottom: 16rpx; padding: 28rpx; }
.article-card text { display: block; }
.article-card__title { color: $ink; font-family: STKaiti, KaiTi, serif; font-size: 31rpx; font-weight: 700; }
.article-card__summary { margin-top: 9rpx; color: $ink-muted; font-size: 24rpx; line-height: 1.45; }
.article-card__meta { margin-top: 10rpx; color: $brand-red; font-size: 21rpx; }
.article-list-state-card { @include adaptive.adaptive-family-content; 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: 35rpx; font-weight: 700; }
+89 -21
View File
@@ -1,13 +1,24 @@
<!-- 页面编号F-05用途谱文详情详情 DTO 未声明时不展示本地正文 -->
<!-- 页面编号F-05用途谱文详情 -->
<template>
<view class="article-detail-page">
<view class="article-detail-page" :class="`article-state--${articleState}`">
<ModulePageBackground module="family" />
<view class="article-detail-header"><PageHeader title="谱文详情" custom-back @back="backToArticles" /></view>
<view class="article-detail-content">
<view class="article-state-card">
<AppLoading v-if="articleState === 'loading'" text="正在读取谱文" description="请稍候,正在同步谱文正文。" />
<view v-else-if="articleState === 'ready'" class="article-card">
<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" />
<text class="article-card__content">{{ article.content || '作者暂未填写正文。' }}</text>
<text class="article-card__views">阅读 {{ article.viewCount }} </text>
</view>
<view v-else class="article-state-card">
<text>{{ stateCopy.title }}</text>
<text>{{ stateCopy.copy }}</text>
<AppButton block :label="stateCopy.action" @click="backToArticles" />
<AppButton block :label="stateCopy.action" @click="handleStateAction" />
</view>
</view>
</view>
@@ -15,44 +26,101 @@
<script setup>
import { computed, ref } from "vue";
import { onLoad } from "@dcloudio/uni-app";
import { onLoad, onShow, onUnload } from "@dcloudio/uni-app";
import AppButton from "@/components/AppButton.vue";
import AppLoading from "@/components/AppLoading.vue";
import ModulePageBackground from "@/components/ModulePageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
import { appApi, createRequestController, isRequestCancelled } from "@/utils/api.js";
import { goBack, returnTo } from "@/utils/navigation.js";
const genealogyId = ref("");
const articleId = ref("");
const hasValidContext = computed(() => /^[1-9]\d*$/.test(genealogyId.value) && /^[1-9]\d*$/.test(articleId.value));
const stateCopy = computed(() => hasValidContext.value
? {
title: "谱文正文待后端字段合同",
copy: "详情接口没有声明文章标题、分类、作者、更新时间或正文段落字段;页面已停止读取本地文章,不能猜测这些字段。",
action: "返回谱文列表",
}
: {
title: "谱文入口无效",
copy: "没有取得有效家谱或文章标识,页面不会回退到其他文章。",
action: "返回上一页",
},
const articleState = ref("loading");
const article = ref(null);
const controller = createRequestController();
let pageActive = true;
const hasValidContext = computed(() =>
/^[1-9]\d*$/.test(genealogyId.value) && /^[1-9]\d*$/.test(articleId.value),
);
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 () => {
if (!hasValidContext.value) {
articleState.value = "invalid";
return;
}
articleState.value = "loading";
try {
const current = await appApi.getArticleDetail(genealogyId.value, articleId.value, { requestController: controller });
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();
});
const backToArticles = () => /^[1-9]\d*$/.test(genealogyId.value)
onShow(() => {
if (hasValidContext.value) void loadArticle();
});
onUnload(() => {
pageActive = false;
controller.abort();
});
const backToArticles = () => hasValidContext.value
? returnTo("F04", { genealogyId: genealogyId.value })
: goBack();
const handleStateAction = () => articleState.value === "error" ? loadArticle() : backToArticles();
</script>
<style scoped lang="scss">
@use "../../styles/adaptive-frame-profiles.scss" as adaptive;
.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 { padding: 18rpx 24rpx 72rpx; }
.article-state-card { @include adaptive.adaptive-family-content; width: 100%; min-height: 350rpx; margin-top: 36rpx; padding: 78rpx 52rpx 50rpx; text-align: center; }
.article-detail-content { flex: 1; padding: 18rpx 24rpx 72rpx; }
.article-card, .article-state-card { @include adaptive.adaptive-family-content; box-sizing: border-box; }
.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: 23rpx; font-weight: 700; }
.article-card__title { margin-top: 14rpx; color: $ink; font-family: STKaiti, KaiTi, serif; font-size: 40rpx; font-weight: 700; line-height: 1.32; }
.article-card__meta { margin-top: 16rpx; color: $ink-muted; font-size: 22rpx; }
.article-card__summary { margin-top: 22rpx; color: $ink-muted; font-size: 25rpx; line-height: 1.6; }
.article-card__divider { height: 1rpx; margin: 26rpx 0; background: rgba(128, 89, 49, .22); }
.article-card__content { color: $ink; font-size: 28rpx; line-height: 1.85; white-space: pre-wrap; }
.article-card__views { margin-top: 30rpx; color: $ink-muted; font-size: 21rpx; 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: 35rpx; font-weight: 700; }
.article-state-card > text:nth-child(2) { margin-top: 18rpx; color: $ink-muted; font-size: 24rpx; line-height: 1.65; }
.article-state-card .app-button { margin-top: 30rpx; }
.article-state-card .app-button { margin-top: 28rpx; }
</style>
+87 -22
View File
@@ -1,4 +1,4 @@
<!-- 页面编号F-06用途已声明 articleTitle/articleContent 创建谱文 -->
<!-- 页面编号F-06用途 Apifox 的完整 AppArticleBody 创建谱文 -->
<template>
<view class="article-editor-page" :class="`article-editor-state--${editorState}`">
<ModulePageBackground module="family" />
@@ -8,17 +8,43 @@
<view class="editor-panel__body">
<text class="editor-eyebrow">服务端创建</text>
<text class="editor-title">把值得传承的故事写下来</text>
<text class="editor-intro">本页只发送 Apifox 已声明且可映射的文章标题正文分类需要 `categoryId`当前没有可用分类 owner故不展示或猜测分类</text>
<text class="editor-intro">标题正文其余字段均可按需填写留空时不提交该字段</text>
<view class="editor-field">
<text class="editor-field__label">文章标题</text>
<view class="editor-control"><input v-model="form.title" maxlength="40" placeholder="请输入文章标题" placeholder-class="editor-placeholder" @input="submitError = ''" /></view>
<text class="editor-field__label">文章分类</text>
<view class="editor-control editor-control--unavailable"><text>服务端暂未提供可选择的分类项</text></view>
</view>
<view class="editor-field">
<text class="editor-field__label">正文内容</text>
<view class="editor-control editor-control--textarea"><textarea v-model="form.content" auto-height maxlength="1200" placeholder="记录家训、往事或想留给后人的话" placeholder-class="editor-placeholder" @input="submitError = ''" /></view>
<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>
<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">
<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 class="editor-field">
<text class="editor-field__label">排序值</text>
<view class="editor-control"><input v-model="form.sortOrder" type="number" placeholder="数值越小越靠前" placeholder-class="editor-placeholder" @input="submitError = ''" /></view>
</view>
<text v-if="submitError" class="editor-save-error">{{ submitError }}</text>
<AppButton block :label="isSubmitting ? '正在提交' : '提交谱文'" :disabled="isSubmitting" @click="submit" />
<AppButton block :label="isSubmitting ? '正在提交' : '提交谱文'" :disabled="isSubmitting || uploading" @click="submit" />
</view>
</view>
<view v-else class="editor-result-card">
@@ -53,28 +79,41 @@ import ModulePageBackground from "@/components/ModulePageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
import { appApi, createRequestController, isRequestCancelled } from "@/utils/api.js";
import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
import { isImagePickCancelled, pickAndUploadImage, toConsumerOssId } from "@/utils/resumable-image-upload.js";
import { goBack, handleBackPress, returnTo, runBackGuard } from "@/utils/navigation.js";
const editorState = ref("form");
const genealogyId = ref("");
const isSubmitting = ref(false);
const uploading = ref(false);
const discardVisible = ref(false);
const submitError = ref("");
const form = reactive({ title: "", content: "" });
const uploadError = ref("");
const coverOssId = ref(null);
const coverFileName = ref("");
const form = reactive({
articleTitle: "",
articleSummary: "",
articleContent: "",
authorName: "",
sortOrder: "",
});
const requestController = createRequestController();
const isDirty = computed(() => Boolean(form.title.trim() || form.content.trim()));
const isDirty = computed(() => Boolean(
Object.values(form).some((value) => value.trim()) || coverOssId.value,
));
const hasValidContext = computed(() => /^[1-9]\d*$/.test(genealogyId.value));
const resultCopy = computed(() => editorState.value === "success"
? {
eyebrow: "服务端已接受",
title: "谱文已提交",
copy: "服务端已返回成功信封。谱文列表与详情仍缺展示 DTO,返回后不会生成本地文章卡片。",
copy: "服务端已返回成功信封。返回谱文列表后将重新读取服务端数据。",
action: "返回谱文列表",
}
: {
eyebrow: "谱文入口无效",
title: "无法创建谱文",
copy: "当前只有新建操作可映射;编辑需要可靠的文章详情和文章 ID,暂不从 fixture 进入。",
copy: "没有取得有效家谱标识,页面不会创建无归属谱文。",
action: "返回上一页",
},
);
@@ -89,20 +128,37 @@ onLoad((query) => {
genealogyId.value = String(query?.genealogyId || "");
if (!hasValidContext.value || query?.mode !== "create") editorState.value = "invalid";
});
const uploadCover = async () => {
if (uploading.value || isSubmitting.value) return;
uploading.value = true;
uploadError.value = "";
try {
const receipt = await pickAndUploadImage({ requestController });
coverOssId.value = toConsumerOssId(receipt.ossId);
coverFileName.value = receipt.fileName || "封面图片";
} catch (error) {
if (!isImagePickCancelled(error) && !isRequestCancelled(error)) {
uploadError.value = error?.message || "封面图片上传失败,请稍后重试。";
}
} finally {
uploading.value = false;
}
};
const submit = async () => {
if (isSubmitting.value || !hasValidContext.value) return;
const articleTitle = form.title.trim();
const articleContent = form.content.trim();
if (!articleTitle || !articleContent) {
submitError.value = !articleTitle ? "请填写文章标题" : "请填写正文内容";
if (isSubmitting.value || uploading.value || !hasValidContext.value) return;
if (!form.articleTitle.trim() || !form.articleContent.trim()) {
submitError.value = !form.articleTitle.trim() ? "请填写文章标题" : "请填写正文内容";
return;
}
isSubmitting.value = true;
submitError.value = "";
try {
await appApi.createArticle(genealogyId.value, { articleTitle, articleContent }, { requestController });
form.title = "";
form.content = "";
await appApi.createArticle(genealogyId.value, {
...form,
coverOssId: coverOssId.value,
}, { requestController });
editorState.value = "success";
} catch (error) {
if (!isRequestCancelled(error)) submitError.value = error?.message || "谱文提交失败,请稍后重试。";
@@ -113,7 +169,7 @@ const submit = async () => {
const requestBack = () => runBackGuard({
transientOpen: discardVisible.value,
dirty: isDirty.value,
submitting: isSubmitting.value,
submitting: isSubmitting.value || uploading.value,
"close-transient": cancelDiscard,
"block-submitting": () => true,
"confirm-discard": requestDiscardConfirmation,
@@ -140,11 +196,20 @@ onUnmounted(() => {
.editor-intro { display: block; margin: 10rpx 8rpx 22rpx; color: $ink-muted; font-size: 23rpx; line-height: 1.55; text-align: center; }
.editor-field { margin-top: 18rpx; }
.editor-field__label { display: block; margin: 0 8rpx 8rpx; color: $ink; font-size: 25rpx; font-weight: 700; }
.required-mark { display: inline-block; margin-right: 4rpx; color: $brand-red; font-size: 26rpx; line-height: 1; transform: translateY(-3rpx); }
.editor-control { @include adaptive.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: 24rpx; }
.editor-control input { min-height: 82rpx; padding: 0 28rpx; }
.editor-control--textarea { min-height: 220rpx; padding: 20rpx 24rpx; }
.editor-control textarea { min-height: 180rpx; line-height: 1.65; }
.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: 23rpx; }
.editor-control textarea { min-height: 92rpx; line-height: 1.65; }
.editor-control--article textarea { min-height: 180rpx; }
.editor-field--cover { display: grid; gap: 12rpx; padding: 18rpx 22rpx; border: 1rpx solid rgba(128, 89, 49, .34); border-radius: 12rpx; background: rgba(255, 252, 245, .7); }
.editor-field--cover .editor-field__label { margin: 0; }
.editor-field__hint { display: block; color: $ink-muted; font-size: 20rpx; line-height: 1.45; }
.upload-button { justify-self: start; min-height: 60rpx; margin: 0; padding: 0 20rpx; border: 1rpx solid rgba(159, 23, 15, .42); border-radius: 8rpx; background: transparent; color: $brand-red; font-size: 22rpx; }
.upload-receipt { color: $ink-muted; font-size: 21rpx; overflow-wrap: anywhere; }
.editor-placeholder { color: #9e8e79; }
.editor-save-error { display: block; margin: 12rpx 8rpx 0; color: $brand-red; font-size: 24rpx; line-height: 34rpx; text-align: center; }
.editor-panel__body > .app-button { margin-top: 22rpx; }
+71 -70
View File
@@ -1,104 +1,105 @@
<!-- 页面编号F-07用途相册入口列表 DTO 缺失时不展示本地相册 -->
<!-- 页面编号F-07用途读取并创建当前家谱的相册 -->
<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 class="album-state-card">
<text>{{ stateCopy.title }}</text>
<text>{{ stateCopy.copy }}</text>
<AppButton block :label="stateCopy.action" @click="handleStateAction" />
<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">
<view v-for="item in albums" :key="item.id" class="album-card" @click="openAlbum(item)"><text>{{ item.name }}</text><text v-if="item.description">{{ item.description }}</text><text>{{ item.photoCount }} 张照片</text></view>
</view>
</view>
<AppDialog :visible="dialogVisible" eyebrow="新建相册" title="为家人整理一段影像" message="仅提交已声明的相册名称;封面、排序和状态没有可用 owner,不会猜测提交。" :confirm-text="isSubmitting ? '正在提交' : '提交相册'" cancel-text="取消" show-cancel :close-on-mask="false" @confirm="submitAlbum" @cancel="closeCreateDialog">
<view class="album-dialog-field">
<text>相册名称</text>
<input v-model="albumNameDraft" maxlength="30" placeholder="例如:春节团圆" @input="submitError = ''" />
<text v-if="submitError">{{ submitError }}</text>
</view>
<AppDialog :visible="dialogVisible" eyebrow="新建相册" title="为家人整理一段影像" :confirm-text="isSubmitting ? '正在提交' : '提交相册'" 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>
<view class="album-dialog-field"><text>排序值</text><input v-model="form.sortOrder" type="number" placeholder="数值越小越靠前" @input="submitError = ''" /></view>
<text v-if="submitError" class="album-field-error">{{ submitError }}</text>
</AppDialog>
</view>
</template>
<script setup>
import { computed, onUnmounted, ref } from "vue";
import { onBackPress, onLoad } from "@dcloudio/uni-app";
import { computed, onUnmounted, reactive, ref } from "vue";
import { onBackPress, onLoad, onShow } 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 { appApi, createRequestController, isRequestCancelled } from "@/utils/api.js";
import { goBack, handleBackPress, runBackGuard } from "@/utils/navigation.js";
import { isImagePickCancelled, pickAndUploadImage, toConsumerOssId } from "@/utils/resumable-image-upload.js";
import { goBack, handleBackPress, openPage, runBackGuard } from "@/utils/navigation.js";
const genealogyId = ref("");
const hasValidContext = ref(false);
const albums = ref([]);
const listState = ref("loading");
const listError = ref("");
const dialogVisible = ref(false);
const albumNameDraft = ref("");
const form = reactive({ albumName: "", albumDesc: "", sortOrder: "" });
const coverOssId = ref(null);
const coverFileName = ref("");
const submitError = ref("");
const uploadError = ref("");
const uploading = ref(false);
const isSubmitting = ref(false);
const created = ref(false);
const requestController = createRequestController();
const stateCopy = computed(() => !hasValidContext.value
? { title: "相册入口无效", copy: "没有取得有效家谱标识,页面不会展示其他家谱相册。", action: "返回上一页" }
: created.value
? { title: "相册已提交服务端", copy: "服务端已返回成功信封;相册列表仍没有条目 DTO,页面不会生成本地相册卡片。", action: "新建相册" }
: { title: "相册列表待后端字段合同", copy: "列表响应没有声明相册 ID、封面、名称、照片数、描述或更新时间字段;页面已停止展示本地相册。", action: "新建相册" },
);
onLoad((query) => {
genealogyId.value = String(query?.genealogyId || "");
hasValidContext.value = /^[1-9]\d*$/.test(genealogyId.value);
});
const openCreateDialog = () => {
if (!hasValidContext.value || isSubmitting.value) return;
albumNameDraft.value = "";
submitError.value = "";
dialogVisible.value = true;
const controller = createRequestController();
let active = true;
const hasValidContext = computed(() => /^[1-9]\d*$/.test(genealogyId.value));
const text = (value) => typeof value === "string" || typeof value === "number" ? String(value).trim() : "";
const toAlbum = (item) => {
const id = text(item?.albumId);
if (!/^[1-9]\d*$/.test(id)) return null;
return { id, name: text(item.albumName) || "未命名相册", description: text(item.albumDesc), photoCount: Number.isSafeInteger(item.photoCount) ? item.photoCount : 0 };
};
const closeCreateDialog = () => {
if (!isSubmitting.value) dialogVisible.value = false;
const loadAlbums = async () => {
if (!hasValidContext.value) return;
controller.abort();
listState.value = "loading";
listError.value = "";
try {
const rows = await appApi.getAlbums(genealogyId.value, { requestController: controller });
if (!active) return;
albums.value = rows.map(toAlbum).filter(Boolean);
listState.value = albums.value.length ? "list" : "empty";
} catch (error) {
if (!active || isRequestCancelled(error)) return;
listState.value = "error";
listError.value = error?.message || "请稍后重试。";
}
};
const resetDraft = () => { Object.assign(form, { albumName: "", albumDesc: "", sortOrder: "" }); coverOssId.value = null; coverFileName.value = ""; submitError.value = ""; uploadError.value = ""; };
const openCreateDialog = () => { if (!hasValidContext.value || isSubmitting.value || uploading.value) return; resetDraft(); 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: controller }); coverOssId.value = toConsumerOssId(receipt.ossId); coverFileName.value = receipt.fileName || "封面图片"; }
catch (error) { if (!isImagePickCancelled(error) && !isRequestCancelled(error)) uploadError.value = error?.message || "封面图片上传失败,请稍后重试。"; }
finally { uploading.value = false; }
};
const submitAlbum = async () => {
if (isSubmitting.value) return;
const albumName = albumNameDraft.value.trim();
if (!albumName) {
submitError.value = "请填写相册名称";
return;
}
if (isSubmitting.value || uploading.value) return;
if (!form.albumName.trim()) { submitError.value = "请填写相册名称"; return; }
isSubmitting.value = true;
submitError.value = "";
try {
await appApi.createAlbum(genealogyId.value, { albumName }, { requestController });
dialogVisible.value = false;
created.value = true;
} catch (error) {
if (!isRequestCancelled(error)) submitError.value = error?.message || "相册提交失败,请稍后重试。";
} finally {
isSubmitting.value = false;
}
try { await appApi.createAlbum(genealogyId.value, { ...form, coverOssId: coverOssId.value }, { requestController: controller }); dialogVisible.value = false; await loadAlbums(); }
catch (error) { if (!isRequestCancelled(error)) submitError.value = error?.message || "相册提交失败,请稍后重试。"; }
finally { isSubmitting.value = false; }
};
const requestBack = () => runBackGuard({
transientOpen: dialogVisible.value,
submitting: isSubmitting.value,
"close-transient": closeCreateDialog,
"block-submitting": () => true,
});
const handleStateAction = () => hasValidContext.value ? openCreateDialog() : goBack();
const openAlbum = (item) => openPage("F08", { genealogyId: genealogyId.value, albumId: item.id }, "F07");
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));
onUnmounted(() => requestController.abort());
onUnmounted(() => { active = false; controller.abort(); });
</script>
<style scoped lang="scss">
@use "../../styles/adaptive-frame-profiles.scss" as adaptive;
.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 72rpx; }
.album-state-card { @include adaptive.adaptive-family-content; width: 100%; min-height: 340rpx; margin-top: 30rpx; padding: 78rpx 52rpx 50rpx; text-align: center; }
.album-state-card > text { display: block; }
.album-state-card > text:first-child { color: $ink; font-family: STKaiti, KaiTi, serif; font-size: 35rpx; font-weight: 700; }
.album-state-card > text:nth-child(2) { margin-top: 18rpx; color: $ink-muted; font-size: 24rpx; line-height: 1.65; }
.album-state-card .app-button { margin-top: 28rpx; }
.album-dialog-field { width: 100%; margin: 24rpx 0; text-align: left; }
.album-dialog-field > text:first-child { display: block; color: $ink; font-size: 23rpx; font-weight: 700; }
.album-dialog-field input { @include adaptive.adaptive-family-field; width: 100%; min-height: 76rpx; margin-top: 10rpx; padding: 0 22rpx; color: $ink; font-size: 24rpx; }
.album-dialog-field > text:last-child { display: block; margin-top: 8rpx; color: $brand-red; font-size: 21rpx; }
.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 72rpx; }.album-state-card, .album-card { @include adaptive.adaptive-family-content; }.album-state-card { min-height: 340rpx; padding: 78rpx 52rpx 50rpx; text-align: center; }.album-state-card text, .album-card text { display: block; }.album-state-card text:first-child, .album-card text:first-child { color: $ink; font-family: STKaiti, KaiTi, serif; font-size: 35rpx; font-weight: 700; }.album-state-card text:nth-child(2), .album-card text:not(:first-child) { margin-top: 12rpx; color: $ink-muted; font-size: 23rpx; line-height: 1.55; }.album-state-card .app-button { margin-top: 28rpx; }.album-list { display: flex; flex-direction: column; gap: 16rpx; }.album-card { padding: 28rpx 30rpx; }.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: 23rpx; font-weight: 700; }.required-mark { display: inline-block; margin-right: 4rpx; color: $brand-red; font-size: 26rpx; }.album-dialog-field input, .album-dialog-field textarea { @include adaptive.adaptive-family-field; box-sizing: border-box; display: block; width: 100%; margin-top: 10rpx; padding: 16rpx 22rpx; color: $ink; font-size: 24rpx; }.album-dialog-field input { min-height: 76rpx; 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, .34); border-radius: 12rpx; background: rgba(255, 252, 245, .7); }.album-field-hint { display: block; margin-top: 6rpx; color: $ink-muted; font-size: 20rpx; line-height: 1.45; }.upload-button { justify-self: start; min-height: 60rpx; margin: 0; padding: 0 20rpx; border: 1rpx solid rgba(159, 23, 15, .42); border-radius: 8rpx; background: transparent; color: $brand-red; font-size: 22rpx; }.upload-receipt { color: $ink-muted; font-size: 21rpx; overflow-wrap: anywhere; }.album-field-error { display: block; margin-top: 8rpx; color: $brand-red; font-size: 21rpx; line-height: 1.45; }
</style>
+44 -28
View File
@@ -1,13 +1,15 @@
<!-- 页面编号F-08用途相册照片墙响应未声明照片展示 DTO 时保持关闭 -->
<!-- 页面编号F-08用途读取当前相册的照片记录 -->
<template>
<view class="album-detail-page">
<ModulePageBackground module="family" />
<view class="album-detail-header"><PageHeader title="相册详情" custom-back @back="returnToAlbums" /></view>
<view class="album-detail-header"><PageHeader title="相册详情" :action="valid ? '添加' : ''" custom-back @back="returnToAlbums" @action="addPhoto" /></view>
<view class="album-detail-content">
<view class="album-state-card">
<text>{{ stateCopy.title }}</text>
<text>{{ stateCopy.copy }}</text>
<AppButton block :label="stateCopy.action" @click="returnToAlbums" />
<view v-if="!valid" class="album-state-card"><text>相册入口无效</text><AppButton block label="返回相册列表" @click="returnToAlbums" /></view>
<view v-else-if="state === 'loading'" class="album-state-card"><AppLoading text="正在读取相册照片" /></view>
<view v-else-if="state === 'error'" class="album-state-card"><text>暂时无法读取相册照片</text><AppButton block type="secondary" label="重新加载" @click="loadPhotos" /></view>
<view v-else-if="state === 'empty'" class="album-state-card"><text>还没有照片</text><text>添加照片后会直接读取服务端相册记录。</text><AppButton block label="添加照片" @click="addPhoto" /></view>
<view v-else class="photo-list">
<view v-for="item in photos" :key="item.id" class="photo-card"><text>{{ item.title }}</text><text v-if="item.description">{{ item.description }}</text><text v-if="item.meta">{{ item.meta }}</text></view>
</view>
</view>
</view>
@@ -15,36 +17,50 @@
<script setup>
import { computed, ref } from "vue";
import { onLoad } from "@dcloudio/uni-app";
import { onLoad, onShow, onUnload } from "@dcloudio/uni-app";
import AppButton from "@/components/AppButton.vue";
import AppLoading from "@/components/AppLoading.vue";
import ModulePageBackground from "@/components/ModulePageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
import { goBack, returnTo } from "@/utils/navigation.js";
import { appApi, createRequestController, isRequestCancelled } from "@/utils/api.js";
import { goBack, openPage, returnTo } from "@/utils/navigation.js";
const genealogyId = ref("");
const albumId = ref("");
const hasValidContext = computed(() => /^[1-9]\d*$/.test(genealogyId.value) && /^[1-9]\d*$/.test(albumId.value));
const stateCopy = computed(() => hasValidContext.value
? { title: "照片墙待后端字段合同", copy: "照片列表没有声明照片 ID、OSS 访问地址、标题、说明或相册展示字段;页面已停止展示本地图片和本地预览。", action: "返回相册列表" }
: { title: "相册入口无效", copy: "没有取得有效家谱或相册标识,页面不会回退到其他相册。", action: "返回上一页" },
);
onLoad((query) => {
genealogyId.value = String(query?.genealogyId || "");
albumId.value = String(query?.albumId || "");
});
const returnToAlbums = () => /^[1-9]\d*$/.test(genealogyId.value)
? returnTo("F07", { genealogyId: genealogyId.value })
: goBack();
const photos = ref([]);
const state = ref("loading");
const controller = createRequestController();
let active = true;
const valid = computed(() => /^[1-9]\d*$/.test(genealogyId.value) && /^[1-9]\d*$/.test(albumId.value));
const loadPhotos = async () => {
if (!valid.value) return;
controller.abort();
state.value = "loading";
try {
const rows = await appApi.getAlbumPhotos(genealogyId.value, albumId.value, { requestController: controller });
if (!active) return;
photos.value = rows.map((item) => ({
id: String(item.photoId || ""),
title: String(item.photoTitle || "未命名照片"),
description: String(item.photoDesc || ""),
meta: [item.photographer, item.shootTime].filter(Boolean).join(" · "),
})).filter((item) => /^[1-9]\d*$/.test(item.id));
state.value = photos.value.length ? "ready" : "empty";
} catch (error) {
if (!active || isRequestCancelled(error)) return;
state.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);
onLoad((query) => { genealogyId.value = String(query?.genealogyId || ""); albumId.value = String(query?.albumId || ""); if (valid.value) loadPhotos(); });
onShow(() => { if (valid.value && state.value !== "loading") loadPhotos(); });
onUnload(() => { active = false; controller.abort(); });
</script>
<style scoped lang="scss">
@use "../../styles/adaptive-frame-profiles.scss" as adaptive;
.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 { @include adaptive.adaptive-family-panel; width: 100%; min-height: 430rpx; padding: 104rpx 40rpx 62rpx; 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: 36rpx; font-weight: 700; }
.album-state-card text:nth-child(2) { margin-top: 10rpx; color: $ink-muted; font-size: 23rpx; line-height: 1.6; }
.album-state-card .app-button { margin-top: 34rpx; }
.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.adaptive-family-content; }.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: 36rpx; font-weight: 700; }.album-state-card text:nth-child(2) { margin-top: 10rpx; color: $ink-muted; font-size: 23rpx; line-height: 1.6; }.album-state-card .app-button { margin-top: 34rpx; }
.photo-list { display: flex; flex-direction: column; gap: 16rpx; }.photo-card { padding: 28rpx 32rpx; }.photo-card text { display: block; }.photo-card text:first-child { color: $ink; font-family: STKaiti, KaiTi, serif; font-size: 30rpx; font-weight: 700; }.photo-card text:not(:first-child) { margin-top: 8rpx; color: $ink-muted; font-size: 22rpx; line-height: 1.5; }
</style>
+151 -16
View File
@@ -1,48 +1,183 @@
<!-- 页面编号F-09用途相册照片写入没有文件上传与 OSS 回执 owner 时关闭 -->
<!-- 页面编号F-09用途上传真实图片并创建相册照片记录 -->
<template>
<view class="media-upload-page">
<view class="media-upload-page" :class="`media-state--${pageState}`">
<ModulePageBackground module="family" />
<view class="media-upload-header"><PageHeader title="添加照片" custom-back @back="returnToAlbum" /></view>
<view class="media-upload-header"><PageHeader title="添加照片" custom-back @back="requestBack" /></view>
<view class="media-upload-content">
<view class="media-state-card">
<text class="media-state-card__eyebrow">文件服务待接入</text>
<text class="media-state-card__title">照片上传暂不可用</text>
<text class="media-state-card__copy">照片写入接口要求已有 `ossId`当前没有经 Apifox 核实的文件上传 owner真实 OSS 回执或访问 URL页面不再从本地图片库选择图片也不会生成上传预览</text>
<AppButton block :label="hasValidContext ? '返回相册列表' : '返回上一页'" @click="returnToAlbum" />
<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">
<text class="media-field__label">拍摄时间</text>
<input v-model="form.shootTime" maxlength="30" placeholder="例如:2026-07-24 10:00:00" placeholder-class="placeholder" @input="submitError = ''" />
</view>
<view class="media-field">
<text class="media-field__label">排序值</text>
<input v-model="form.sortOrder" type="number" placeholder="数值越小越靠前" placeholder-class="placeholder" @input="submitError = ''" />
</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, ref } from "vue";
import { onLoad } from "@dcloudio/uni-app";
import { computed, onUnmounted, reactive, ref } from "vue";
import { onBackPress, onLoad } 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 { goBack, returnTo } from "@/utils/navigation.js";
import { appApi, createRequestController, isRequestCancelled } from "@/utils/api.js";
import { isImagePickCancelled, pickAndUploadImage, toConsumerOssId } from "@/utils/resumable-image-upload.js";
import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
import { goBack, handleBackPress, returnTo, runBackGuard } from "@/utils/navigation.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: "", shootTime: "", sortOrder: "" });
const controller = createRequestController();
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(() => receipt.value || Object.values(form).some((value) => value.trim()));
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 {
receipt.value = await pickAndUploadImage({ requestController: controller });
} catch (error) {
if (!isImagePickCancelled(error) && !isRequestCancelled(error)) {
uploadError.value = error?.message || "图片上传失败,请稍后重试";
}
} finally {
uploading.value = false;
}
};
const submitPhoto = async () => {
if (uploading.value || submitting.value || !hasValidContext.value) return;
if (!receipt.value) {
submitError.value = "请先选择并上传照片";
return;
}
submitting.value = true;
submitError.value = "";
try {
await appApi.createAlbumPhoto(genealogyId.value, albumId.value, {
...form,
ossId: toConsumerOssId(receipt.value.ossId),
}, { requestController: controller });
pageState.value = "success";
} catch (error) {
if (!isRequestCancelled(error)) submitError.value = error?.message || "照片保存失败,请稍后重试";
} finally {
submitting.value = false;
}
};
const returnToAlbum = () => hasValidContext.value
? returnTo("F07", { genealogyId: genealogyId.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));
onUnmounted(() => {
controller.abort();
confirmation.dispose();
});
</script>
<style scoped lang="scss">
@use "../../styles/adaptive-frame-profiles.scss" as adaptive;
.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 { padding: 20rpx 24rpx calc(48rpx + env(safe-area-inset-bottom)); }
.media-state-card { @include adaptive.adaptive-family-panel; min-height: 410rpx; box-sizing: border-box; padding: 98rpx 46rpx 58rpx; text-align: center; }
.media-upload-content { flex: 1; padding: 20rpx 24rpx calc(48rpx + env(safe-area-inset-bottom)); }
.media-panel, .media-state-card { @include adaptive.adaptive-family-panel; box-sizing: border-box; }
.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: 36rpx; font-weight: 700; }
.media-panel__note { margin-top: 10rpx; color: $ink-muted; font-size: 23rpx; line-height: 1.55; }
.media-field { margin-top: 20rpx; }
.media-field__label { margin: 0 6rpx 8rpx; color: $ink; font-size: 25rpx; font-weight: 700; }
.required-mark { display: inline-block; margin-right: 4rpx; color: $brand-red; font-size: 26rpx; line-height: 1; transform: translateY(-3rpx); }
.media-field input, .media-field textarea { @include adaptive.adaptive-family-field; box-sizing: border-box; display: block; width: 100%; color: $ink; font-size: 24rpx; }
.media-field input { min-height: 78rpx; padding: 0 22rpx; }
.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, .34); border-radius: 12rpx; background: rgba(255, 252, 245, .7); }
.media-field--upload .media-field__label { margin: 0; }
.upload-button { justify-self: start; min-height: 60rpx; margin: 0; padding: 0 20rpx; border: 1rpx solid rgba(159, 23, 15, .42); border-radius: 8rpx; background: transparent; color: $brand-red; font-size: 22rpx; }
.upload-receipt { color: $ink-muted; font-size: 21rpx; overflow-wrap: anywhere; }
.field-error { margin-top: 10rpx; color: $brand-red; font-size: 22rpx; 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__eyebrow { color: $brand-red; font-size: 22rpx; letter-spacing: 2rpx; }
.media-state-card__title { margin-top: 12rpx; color: $ink; font-family: STKaiti, KaiTi, serif; font-size: 35rpx; font-weight: 700; }
.media-state-card__title { color: $ink; font-family: STKaiti, KaiTi, serif; font-size: 35rpx; font-weight: 700; }
.media-state-card__copy { margin-top: 16rpx; color: $ink-muted; font-size: 23rpx; line-height: 1.65; }
.media-state-card .app-button { margin-top: 30rpx; }
</style>