修改部分样式
This commit is contained in:
@@ -1,14 +1,53 @@
|
||||
<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="family-page__header"
|
||||
><PageHeader
|
||||
root
|
||||
title="家族动态"
|
||||
:action="hasValidContext ? '发布' : ''"
|
||||
@action="toPublish"
|
||||
/></view>
|
||||
<view class="feed-content">
|
||||
<view class="feed-heading"><text>家族圈</text><text>家宴、通知与共同记忆</text></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 class="feed-heading"
|
||||
><text>家族圈</text><text>家宴、通知与共同记忆</text></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" />
|
||||
</view>
|
||||
@@ -25,20 +64,206 @@ 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("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();
|
||||
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-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}
|
||||
.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;
|
||||
min-height: 76rpx;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff9ed;
|
||||
font-size: 24rpx;
|
||||
font-weight: 700;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -2,15 +2,25 @@
|
||||
<template>
|
||||
<view class="publish-page" :class="`publish-state--${publishState}`">
|
||||
<ModulePageBackground module="family" />
|
||||
<view class="publish-page__header"><PageHeader title="发布动态" custom-back @back="requestBack" /></view>
|
||||
<view class="publish-page__header"
|
||||
><PageHeader title="发布动态" custom-back @back="requestBack"
|
||||
/></view>
|
||||
<view class="publish-panel">
|
||||
<view v-if="publishState === 'form'" class="publish-form">
|
||||
<text>记录此刻</text>
|
||||
<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 = ''" />
|
||||
<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>
|
||||
@@ -22,23 +32,53 @@
|
||||
<view class="publish-field publish-field--media">
|
||||
<view>
|
||||
<text class="publish-field__label">动态配图</text>
|
||||
<text class="publish-field__hint">图片选定后会先取得真实上传回执,并在提交动态时关联。</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>
|
||||
<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 = ''" />
|
||||
<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 || isUploading" @click="submit" />
|
||||
<AppButton
|
||||
block
|
||||
:label="isSubmitting ? '正在提交' : '提交动态'"
|
||||
:disabled="isSubmitting || isUploading"
|
||||
@click="submit"
|
||||
/>
|
||||
</view>
|
||||
<view v-else class="publish-result">
|
||||
<text>{{ resultCopy.title }}</text>
|
||||
<text>{{ resultCopy.copy }}</text>
|
||||
<AppButton block :label="resultCopy.action" @click="handleResultAction" />
|
||||
<AppButton
|
||||
block
|
||||
:label="resultCopy.action"
|
||||
@click="handleResultAction"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
<AppDialog
|
||||
@@ -62,10 +102,22 @@ 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 { appApi, createRequestController, isRequestCancelled } from "@/utils/api.js";
|
||||
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";
|
||||
import {
|
||||
isImagePickCancelled,
|
||||
pickAndUploadImage,
|
||||
} from "@/utils/resumable-image-upload.js";
|
||||
import {
|
||||
goBack,
|
||||
handleBackPress,
|
||||
returnTo,
|
||||
runBackGuard,
|
||||
} from "@/utils/navigation.js";
|
||||
|
||||
const genealogyId = ref("");
|
||||
const form = reactive({ feedContent: "", feedType: "text", sortOrder: "" });
|
||||
@@ -77,28 +129,37 @@ const submitError = ref("");
|
||||
const uploadError = ref("");
|
||||
const discardVisible = ref(false);
|
||||
const requestController = createRequestController();
|
||||
const mediaOssIds = computed(() => mediaReceipts.value.map((item) => item.ossId).join(","));
|
||||
const isDirty = computed(() => Boolean(
|
||||
form.feedContent.trim() || form.sortOrder.trim() || mediaReceipts.value.length,
|
||||
));
|
||||
const mediaOssIds = computed(() =>
|
||||
mediaReceipts.value.map((item) => item.ossId).join(","),
|
||||
);
|
||||
const isDirty = computed(() =>
|
||||
Boolean(
|
||||
form.feedContent.trim() ||
|
||||
form.sortOrder.trim() ||
|
||||
mediaReceipts.value.length,
|
||||
),
|
||||
);
|
||||
const hasValidContext = computed(() => /^[1-9]\d*$/.test(genealogyId.value));
|
||||
const resultCopy = computed(() => ({
|
||||
success: {
|
||||
title: "动态已提交服务端",
|
||||
copy: "服务端已返回成功信封。返回动态列表后将重新读取服务端数据。",
|
||||
action: "返回家族动态",
|
||||
},
|
||||
error: {
|
||||
title: "动态未提交",
|
||||
copy: "当前内容仍保留在页面中,可返回表单继续核对。",
|
||||
action: "返回填写",
|
||||
},
|
||||
invalid: {
|
||||
title: "动态入口无效",
|
||||
copy: "没有取得有效家谱标识,页面不会创建无归属动态。",
|
||||
action: "返回上一页",
|
||||
},
|
||||
}[publishState.value]));
|
||||
const resultCopy = computed(
|
||||
() =>
|
||||
({
|
||||
success: {
|
||||
title: "动态已提交服务端",
|
||||
copy: "服务端已返回成功信封。返回动态列表后将重新读取服务端数据。",
|
||||
action: "返回家族动态",
|
||||
},
|
||||
error: {
|
||||
title: "动态未提交",
|
||||
copy: "当前内容仍保留在页面中,可返回表单继续核对。",
|
||||
action: "返回填写",
|
||||
},
|
||||
invalid: {
|
||||
title: "动态入口无效",
|
||||
copy: "没有取得有效家谱标识,页面不会创建无归属动态。",
|
||||
action: "返回上一页",
|
||||
},
|
||||
})[publishState.value],
|
||||
);
|
||||
const discardConfirmation = createDiscardConfirmation((visible) => {
|
||||
discardVisible.value = visible;
|
||||
});
|
||||
@@ -136,12 +197,16 @@ const submit = async () => {
|
||||
isSubmitting.value = true;
|
||||
submitError.value = "";
|
||||
try {
|
||||
await appApi.createFeed(genealogyId.value, {
|
||||
feedContent: form.feedContent,
|
||||
feedType: form.feedType,
|
||||
mediaOssIds: mediaOssIds.value,
|
||||
sortOrder: form.sortOrder,
|
||||
}, { requestController });
|
||||
await appApi.createFeed(
|
||||
genealogyId.value,
|
||||
{
|
||||
feedContent: form.feedContent,
|
||||
feedType: form.feedType,
|
||||
mediaOssIds: mediaOssIds.value,
|
||||
sortOrder: form.sortOrder,
|
||||
},
|
||||
{ requestController },
|
||||
);
|
||||
Object.assign(form, { feedContent: "", feedType: "text", sortOrder: "" });
|
||||
mediaReceipts.value = [];
|
||||
publishState.value = "success";
|
||||
@@ -153,14 +218,15 @@ const submit = async () => {
|
||||
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 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;
|
||||
@@ -183,28 +249,138 @@ onUnmounted(() => {
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use "../../styles/adaptive-frame-profiles.scss" as adaptive;
|
||||
.publish-page { display: flex; min-height: 100vh; flex-direction: column; background: $paper; }
|
||||
.publish-page__header, .publish-panel { @include adaptive.adaptive-family-content; z-index: 1; }
|
||||
.publish-panel { width: calc(100% - 32rpx); margin: 18rpx auto 0; padding: 9%; box-sizing: border-box; }
|
||||
.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; 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__value--readonly { display:flex; min-height:76rpx; align-items:center; justify-content:space-between; margin-top:12rpx; padding:0 20rpx; border:1rpx solid rgba(128,89,49,.22); border-radius:10rpx; background:rgba(255,252,245,.48); }
|
||||
.publish-field__value--readonly text:first-child { color:$ink; font-size:24rpx; }
|
||||
.publish-field__value--readonly text:last-child { color:$ink-muted; font-size:20rpx; }
|
||||
.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: 88rpx; 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; }
|
||||
.publish-result { margin-top: 20%; text-align: center; }
|
||||
.publish-result > .app-button { margin: 30rpx auto 0; }
|
||||
.publish-page {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
flex-direction: column;
|
||||
background: $paper;
|
||||
}
|
||||
.publish-page__header,
|
||||
.publish-panel {
|
||||
@include adaptive.adaptive-family-content;
|
||||
z-index: 1;
|
||||
}
|
||||
.publish-panel {
|
||||
width: calc(100% - 32rpx);
|
||||
margin: 18rpx auto 0;
|
||||
padding: 9%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.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;
|
||||
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__value--readonly {
|
||||
display: flex;
|
||||
min-height: 76rpx;
|
||||
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: 24rpx;
|
||||
}
|
||||
.publish-field__value--readonly text:last-child {
|
||||
color: $ink-muted;
|
||||
font-size: 20rpx;
|
||||
}
|
||||
.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;
|
||||
margin: 0;
|
||||
padding: 0 20rpx;
|
||||
border: 1rpx solid rgba(159, 23, 15, 0.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;
|
||||
}
|
||||
.publish-result {
|
||||
margin-top: 20%;
|
||||
text-align: center;
|
||||
}
|
||||
.publish-result > .app-button {
|
||||
margin: 30rpx auto 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -2,30 +2,49 @@
|
||||
<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-header"
|
||||
><PageHeader title="动态详情" custom-back @back="requestBack"
|
||||
/></view>
|
||||
|
||||
<view class="feed-detail-content">
|
||||
<AppLoading v-if="feedState === 'loading'" text="正在读取动态" description="请稍候,正在同步家族动态与评论。" />
|
||||
<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>{{ feed.time || '未标注时间' }}</text>
|
||||
<text>{{ feed.time || "未标注时间" }}</text>
|
||||
</view>
|
||||
<text class="feed-card__content">{{ feed.content }}</text>
|
||||
<view class="feed-card__meta">
|
||||
<text>发布:{{ feed.publisher }}</text>
|
||||
<text>{{ feed.likeCount }} 次点赞 · {{ feed.commentCount }} 条评论</text>
|
||||
<text
|
||||
>{{ feed.likeCount }} 次点赞 ·
|
||||
{{ feed.commentCount }} 条评论</text
|
||||
>
|
||||
</view>
|
||||
<AppButton
|
||||
compact
|
||||
type="secondary"
|
||||
:disabled="isTogglingLike || !hasLikeState"
|
||||
:label="isTogglingLike ? '正在提交' : (hasLikeState ? (feed.likedByMe ? '取消点赞' : '点赞') : '点赞状态不可用')"
|
||||
:label="
|
||||
isTogglingLike
|
||||
? '正在提交'
|
||||
: hasLikeState
|
||||
? feed.likedByMe
|
||||
? '取消点赞'
|
||||
: '点赞'
|
||||
: '点赞状态不可用'
|
||||
"
|
||||
@click="toggleLike"
|
||||
/>
|
||||
<text v-if="!hasLikeState" class="like-note">点赞状态暂不可用,页面不会猜测下一次操作。</text>
|
||||
<text v-if="!hasLikeState" class="like-note"
|
||||
>点赞状态暂不可用,页面不会猜测下一次操作。</text
|
||||
>
|
||||
<text v-if="likeError" class="like-error">{{ likeError }}</text>
|
||||
</view>
|
||||
|
||||
@@ -36,7 +55,7 @@
|
||||
<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>
|
||||
<text>{{ item.time || "刚刚" }}</text>
|
||||
</view>
|
||||
<text class="comment-card__content">{{ item.content }}</text>
|
||||
</view>
|
||||
@@ -52,7 +71,9 @@
|
||||
placeholder-class="comment-editor__placeholder"
|
||||
@input="commentError = ''"
|
||||
/>
|
||||
<text v-if="commentError" class="comment-error">{{ commentError }}</text>
|
||||
<text v-if="commentError" class="comment-error">{{
|
||||
commentError
|
||||
}}</text>
|
||||
<AppButton
|
||||
block
|
||||
:disabled="isSubmittingComment"
|
||||
@@ -66,7 +87,12 @@
|
||||
<view v-else class="feed-state-card">
|
||||
<text>{{ stateCopy.title }}</text>
|
||||
<text>{{ stateCopy.copy }}</text>
|
||||
<AppButton type="secondary" block :label="stateCopy.action" @click="handleStateAction" />
|
||||
<AppButton
|
||||
type="secondary"
|
||||
block
|
||||
:label="stateCopy.action"
|
||||
@click="handleStateAction"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
@@ -79,7 +105,11 @@ 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 {
|
||||
appApi,
|
||||
createRequestController,
|
||||
isRequestCancelled,
|
||||
} from "@/utils/api.js";
|
||||
import { goBack, handleBackPress, returnTo } from "@/utils/navigation.js";
|
||||
|
||||
const genealogyId = ref("");
|
||||
@@ -95,10 +125,15 @@ const isTogglingLike = ref(false);
|
||||
const likeError = ref("");
|
||||
const controller = createRequestController();
|
||||
let pageActive = true;
|
||||
const feedTypeLabel = (type) => String(type || "").trim().toLowerCase() === "text" ? "文字动态" : "家族动态";
|
||||
const feedTypeLabel = (type) =>
|
||||
String(type || "")
|
||||
.trim()
|
||||
.toLowerCase() === "text"
|
||||
? "文字动态"
|
||||
: "家族动态";
|
||||
|
||||
const hasValidContext = computed(() =>
|
||||
/^[1-9]\d*$/.test(genealogyId.value) && /^[1-9]\d*$/.test(feedId.value),
|
||||
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(() => {
|
||||
@@ -134,13 +169,20 @@ const loadFeed = async () => {
|
||||
}
|
||||
feedState.value = "loading";
|
||||
try {
|
||||
const current = await appApi.getFeedDetail(genealogyId.value, feedId.value, { requestController: controller });
|
||||
const current = await appApi.getFeedDetail(
|
||||
genealogyId.value,
|
||||
feedId.value,
|
||||
{ requestController: controller },
|
||||
);
|
||||
if (!pageActive) return;
|
||||
feed.value = current;
|
||||
feedState.value = "ready";
|
||||
} catch (error) {
|
||||
if (!pageActive || isRequestCancelled(error)) return;
|
||||
feedState.value = error?.code === "HTTP_ERROR" && error?.httpStatus === 404 ? "missing" : "error";
|
||||
feedState.value =
|
||||
error?.code === "HTTP_ERROR" && error?.httpStatus === 404
|
||||
? "missing"
|
||||
: "error";
|
||||
}
|
||||
};
|
||||
|
||||
@@ -148,7 +190,9 @@ 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 });
|
||||
const rows = await appApi.getFeedComments(genealogyId.value, feedId.value, {
|
||||
requestController: controller,
|
||||
});
|
||||
if (!pageActive) return;
|
||||
comments.value = rows;
|
||||
commentState.value = rows.length ? "list" : "empty";
|
||||
@@ -164,12 +208,20 @@ const refresh = async () => {
|
||||
};
|
||||
|
||||
const toggleLike = async () => {
|
||||
if (isTogglingLike.value || !hasValidContext.value || !feed.value || !hasLikeState.value) return;
|
||||
if (
|
||||
isTogglingLike.value ||
|
||||
!hasValidContext.value ||
|
||||
!feed.value ||
|
||||
!hasLikeState.value
|
||||
)
|
||||
return;
|
||||
const nextLiked = !feed.value.likedByMe;
|
||||
isTogglingLike.value = true;
|
||||
likeError.value = "";
|
||||
try {
|
||||
await appApi.setFeedLike(genealogyId.value, feedId.value, nextLiked, { requestController: controller });
|
||||
await appApi.setFeedLike(genealogyId.value, feedId.value, nextLiked, {
|
||||
requestController: controller,
|
||||
});
|
||||
if (!pageActive) return;
|
||||
await loadFeed();
|
||||
} catch (error) {
|
||||
@@ -190,7 +242,12 @@ const submitComment = async () => {
|
||||
isSubmittingComment.value = true;
|
||||
commentError.value = "";
|
||||
try {
|
||||
await appApi.createFeedComment(genealogyId.value, feedId.value, { commentContent }, { requestController: controller });
|
||||
await appApi.createFeedComment(
|
||||
genealogyId.value,
|
||||
feedId.value,
|
||||
{ commentContent },
|
||||
{ requestController: controller },
|
||||
);
|
||||
if (!pageActive) return;
|
||||
commentDraft.value = "";
|
||||
await refresh();
|
||||
@@ -215,46 +272,176 @@ onUnload(() => {
|
||||
controller.abort();
|
||||
});
|
||||
|
||||
const backToFamily = () => hasValidContext.value
|
||||
? returnTo("F01", { genealogyId: genealogyId.value })
|
||||
: goBack();
|
||||
const backToFamily = () =>
|
||||
hasValidContext.value
|
||||
? returnTo("F01", { genealogyId: genealogyId.value })
|
||||
: goBack();
|
||||
const requestBack = () => backToFamily();
|
||||
const handleStateAction = () => feedState.value === "error" ? refresh() : backToFamily();
|
||||
const handleStateAction = () =>
|
||||
feedState.value === "error" ? refresh() : backToFamily();
|
||||
onBackPress((event) => handleBackPress(event, requestBack));
|
||||
</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-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; }
|
||||
.feed-card .app-button { margin-top: 18rpx; }
|
||||
.like-note, .like-error { display: block; margin-top: 10rpx; font-size: 22rpx; }
|
||||
.like-note { color: $ink-muted; }
|
||||
.like-error { color: $brand-red; }
|
||||
.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; }
|
||||
.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 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;
|
||||
}
|
||||
.feed-card .app-button {
|
||||
margin-top: 18rpx;
|
||||
}
|
||||
.like-note,
|
||||
.like-error {
|
||||
display: block;
|
||||
margin-top: 10rpx;
|
||||
font-size: 22rpx;
|
||||
}
|
||||
.like-note {
|
||||
color: $ink-muted;
|
||||
}
|
||||
.like-error {
|
||||
color: $brand-red;
|
||||
}
|
||||
.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, 0.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, 0.18);
|
||||
}
|
||||
.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: 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;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -2,13 +2,28 @@
|
||||
<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-header"
|
||||
><PageHeader
|
||||
title="谱文"
|
||||
:action="hasValidContext ? '新建' : ''"
|
||||
@action="createArticle"
|
||||
/></view>
|
||||
<view class="article-list-content">
|
||||
<view v-if="listState === 'list'" class="article-list-items">
|
||||
<view v-for="item in articles" :key="item.id" class="article-card" @click="openArticle(item)">
|
||||
<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>
|
||||
<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">
|
||||
@@ -33,44 +48,60 @@ const genealogyId = ref("");
|
||||
const hasValidContext = ref(false);
|
||||
const listState = ref("loading");
|
||||
const articles = ref([]);
|
||||
const stateCopy = computed(() => hasValidContext.value
|
||||
? listState.value === "empty" ? {
|
||||
title: "还没有谱文",
|
||||
copy: "新建第一篇谱文,记录值得传承的故事。",
|
||||
action: "新建谱文",
|
||||
} : listState.value === "error" ? {
|
||||
title: "谱文暂时无法读取",
|
||||
copy: "请检查网络后重新读取。",
|
||||
action: "重新读取",
|
||||
} : {
|
||||
title: "正在读取谱文",
|
||||
copy: "请稍候。",
|
||||
action: "重新读取",
|
||||
}
|
||||
: {
|
||||
title: "谱文入口无效",
|
||||
copy: "没有取得有效家谱标识,页面不会展示其他家谱内容。",
|
||||
action: "返回上一页",
|
||||
},
|
||||
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";
|
||||
if (hasValidContext.value) void loadArticles();
|
||||
else listState.value = "invalid";
|
||||
});
|
||||
onShow(() => {
|
||||
if (hasValidContext.value) void loadArticles();
|
||||
});
|
||||
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"; }
|
||||
} catch {
|
||||
listState.value = "error";
|
||||
}
|
||||
};
|
||||
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 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 handleStateAction = () => {
|
||||
if (!hasValidContext.value) return goBack();
|
||||
return listState.value === "error" ? loadArticles() : createArticle();
|
||||
@@ -79,18 +110,76 @@ const handleStateAction = () => {
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use "../../styles/adaptive-frame-profiles.scss" as adaptive;
|
||||
.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; }
|
||||
.article-list-state-card > text:nth-child(2) { margin-top: 18rpx; color: $ink-muted; font-size: 24rpx; line-height: 1.65; }
|
||||
.article-list-state-card .app-button { margin-top: 28rpx; }
|
||||
.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;
|
||||
}
|
||||
.article-list-state-card > text:nth-child(2) {
|
||||
margin-top: 18rpx;
|
||||
color: $ink-muted;
|
||||
font-size: 24rpx;
|
||||
line-height: 1.65;
|
||||
}
|
||||
.article-list-state-card .app-button {
|
||||
margin-top: 28rpx;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -2,17 +2,31 @@
|
||||
<template>
|
||||
<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-header"
|
||||
><PageHeader title="谱文详情" custom-back @back="backToArticles"
|
||||
/></view>
|
||||
|
||||
<view class="article-detail-content">
|
||||
<AppLoading v-if="articleState === 'loading'" text="正在读取谱文" description="请稍候,正在同步谱文正文。" />
|
||||
<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 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>
|
||||
<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__content">{{
|
||||
article.content || "作者暂未填写正文。"
|
||||
}}</text>
|
||||
<text class="article-card__views">阅读 {{ article.viewCount }} 次</text>
|
||||
</view>
|
||||
<view v-else class="article-state-card">
|
||||
@@ -31,7 +45,11 @@ 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 {
|
||||
appApi,
|
||||
createRequestController,
|
||||
isRequestCancelled,
|
||||
} from "@/utils/api.js";
|
||||
import { goBack, returnTo } from "@/utils/navigation.js";
|
||||
|
||||
const genealogyId = ref("");
|
||||
@@ -41,8 +59,9 @@ 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 hasValidContext = computed(
|
||||
() =>
|
||||
/^[1-9]\d*$/.test(genealogyId.value) && /^[1-9]\d*$/.test(articleId.value),
|
||||
);
|
||||
const stateCopy = computed(() => {
|
||||
if (!hasValidContext.value) {
|
||||
@@ -73,13 +92,20 @@ const loadArticle = async () => {
|
||||
}
|
||||
articleState.value = "loading";
|
||||
try {
|
||||
const current = await appApi.getArticleDetail(genealogyId.value, articleId.value, { requestController: controller });
|
||||
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";
|
||||
articleState.value =
|
||||
error?.code === "HTTP_ERROR" && error?.httpStatus === 404
|
||||
? "missing"
|
||||
: "error";
|
||||
}
|
||||
};
|
||||
|
||||
@@ -96,31 +122,111 @@ onUnload(() => {
|
||||
controller.abort();
|
||||
});
|
||||
|
||||
const backToArticles = () => hasValidContext.value
|
||||
? returnTo("F04", { genealogyId: genealogyId.value })
|
||||
: goBack();
|
||||
const handleStateAction = () => articleState.value === "error" ? loadArticle() : backToArticles();
|
||||
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 { 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: 28rpx; }
|
||||
.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 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, 0.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: 28rpx;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,50 +1,119 @@
|
||||
<!-- 页面编号:F-06;用途:按 Apifox 的完整 AppArticleBody 创建谱文。 -->
|
||||
<template>
|
||||
<view class="article-editor-page" :class="`article-editor-state--${editorState}`">
|
||||
<view
|
||||
class="article-editor-page"
|
||||
:class="`article-editor-state--${editorState}`"
|
||||
>
|
||||
<ModulePageBackground module="family" />
|
||||
<view class="article-editor-page__header"><PageHeader title="新建谱文" custom-back @back="requestBack" /></view>
|
||||
<view class="article-editor-page__header"
|
||||
><PageHeader title="新建谱文" custom-back @back="requestBack"
|
||||
/></view>
|
||||
<view class="article-editor-content">
|
||||
<view v-if="editorState === 'form'" class="editor-panel">
|
||||
<view class="editor-panel__body">
|
||||
<text class="editor-eyebrow">服务端创建</text>
|
||||
<text class="editor-title">把值得传承的故事写下来</text>
|
||||
<text class="editor-intro">除标题和正文外,其余字段均可按需填写;留空时不提交该字段。</text>
|
||||
<text class="editor-intro"
|
||||
>除标题和正文外,其余字段均可按需填写;留空时不提交该字段。</text
|
||||
>
|
||||
|
||||
<view class="editor-field">
|
||||
<text class="editor-field__label">文章分类</text>
|
||||
<view class="editor-control editor-control--unavailable"><text>服务端暂未提供可选择的分类项</text></view>
|
||||
<view class="editor-control editor-control--unavailable"
|
||||
><text>服务端暂未提供可选择的分类项</text></view
|
||||
>
|
||||
</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>
|
||||
<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 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>
|
||||
<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>
|
||||
<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>
|
||||
<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 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 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 || uploading" @click="submit" />
|
||||
<text v-if="submitError" class="editor-save-error">{{
|
||||
submitError
|
||||
}}</text>
|
||||
<AppButton
|
||||
block
|
||||
:label="isSubmitting ? '正在提交' : '提交谱文'"
|
||||
:disabled="isSubmitting || uploading"
|
||||
@click="submit"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
<view v-else class="editor-result-card">
|
||||
@@ -52,7 +121,11 @@
|
||||
<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" />
|
||||
<AppButton
|
||||
block
|
||||
:label="resultCopy.action"
|
||||
@click="handleResultAction"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
@@ -77,10 +150,22 @@ 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 { appApi, createRequestController, isRequestCancelled } from "@/utils/api.js";
|
||||
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";
|
||||
import {
|
||||
isImagePickCancelled,
|
||||
pickAndUploadImage,
|
||||
} from "@/utils/resumable-image-upload.js";
|
||||
import {
|
||||
goBack,
|
||||
handleBackPress,
|
||||
returnTo,
|
||||
runBackGuard,
|
||||
} from "@/utils/navigation.js";
|
||||
|
||||
const editorState = ref("form");
|
||||
const genealogyId = ref("");
|
||||
@@ -99,23 +184,26 @@ const form = reactive({
|
||||
sortOrder: "",
|
||||
});
|
||||
const requestController = createRequestController();
|
||||
const isDirty = computed(() => Boolean(
|
||||
Object.values(form).some((value) => value.trim()) || coverOssId.value,
|
||||
));
|
||||
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: "服务端已返回成功信封。返回谱文列表后将重新读取服务端数据。",
|
||||
action: "返回谱文列表",
|
||||
}
|
||||
: {
|
||||
eyebrow: "谱文入口无效",
|
||||
title: "无法创建谱文",
|
||||
copy: "没有取得有效家谱标识,页面不会创建无归属谱文。",
|
||||
action: "返回上一页",
|
||||
},
|
||||
const resultCopy = computed(() =>
|
||||
editorState.value === "success"
|
||||
? {
|
||||
eyebrow: "服务端已接受",
|
||||
title: "谱文已提交",
|
||||
copy: "服务端已返回成功信封。返回谱文列表后将重新读取服务端数据。",
|
||||
action: "返回谱文列表",
|
||||
}
|
||||
: {
|
||||
eyebrow: "谱文入口无效",
|
||||
title: "无法创建谱文",
|
||||
copy: "没有取得有效家谱标识,页面不会创建无归属谱文。",
|
||||
action: "返回上一页",
|
||||
},
|
||||
);
|
||||
const discardConfirmation = createDiscardConfirmation((visible) => {
|
||||
discardVisible.value = visible;
|
||||
@@ -126,7 +214,8 @@ const cancelDiscard = discardConfirmation.cancel;
|
||||
|
||||
onLoad((query) => {
|
||||
genealogyId.value = String(query?.genealogyId || "");
|
||||
if (!hasValidContext.value || query?.mode !== "create") editorState.value = "invalid";
|
||||
if (!hasValidContext.value || query?.mode !== "create")
|
||||
editorState.value = "invalid";
|
||||
});
|
||||
|
||||
const uploadCover = async () => {
|
||||
@@ -149,34 +238,43 @@ const uploadCover = async () => {
|
||||
const submit = async () => {
|
||||
if (isSubmitting.value || uploading.value || !hasValidContext.value) return;
|
||||
if (!form.articleTitle.trim() || !form.articleContent.trim()) {
|
||||
submitError.value = !form.articleTitle.trim() ? "请填写文章标题" : "请填写正文内容";
|
||||
submitError.value = !form.articleTitle.trim()
|
||||
? "请填写文章标题"
|
||||
: "请填写正文内容";
|
||||
return;
|
||||
}
|
||||
isSubmitting.value = true;
|
||||
submitError.value = "";
|
||||
try {
|
||||
await appApi.createArticle(genealogyId.value, {
|
||||
...form,
|
||||
coverOssId: coverOssId.value,
|
||||
}, { requestController });
|
||||
await appApi.createArticle(
|
||||
genealogyId.value,
|
||||
{
|
||||
...form,
|
||||
coverOssId: coverOssId.value,
|
||||
},
|
||||
{ requestController },
|
||||
);
|
||||
editorState.value = "success";
|
||||
} catch (error) {
|
||||
if (!isRequestCancelled(error)) submitError.value = error?.message || "谱文提交失败,请稍后重试。";
|
||||
if (!isRequestCancelled(error))
|
||||
submitError.value = error?.message || "谱文提交失败,请稍后重试。";
|
||||
} finally {
|
||||
isSubmitting.value = false;
|
||||
}
|
||||
};
|
||||
const requestBack = () => 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"
|
||||
? returnTo("F04", { genealogyId: genealogyId.value })
|
||||
: goBack();
|
||||
const requestBack = () =>
|
||||
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"
|
||||
? returnTo("F04", { genealogyId: genealogyId.value })
|
||||
: goBack();
|
||||
onBackPress((event) => handleBackPress(event, requestBack));
|
||||
onUnmounted(() => {
|
||||
requestController.abort();
|
||||
@@ -186,36 +284,175 @@ onUnmounted(() => {
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use "../../styles/adaptive-frame-profiles.scss" as adaptive;
|
||||
.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.adaptive-family-panel; width: 100%; }
|
||||
.editor-panel__body { padding: 38rpx 34rpx 42rpx; }
|
||||
.editor-eyebrow { display: block; color: $brand-red; font-size: 23rpx; letter-spacing: 3rpx; text-align: center; }
|
||||
.editor-title { display: block; margin-top: 10rpx; color: $ink; font-family: STKaiti, KaiTi, serif; font-size: 34rpx; font-weight: 700; text-align: center; }
|
||||
.editor-intro { display: block; margin: 10rpx 8rpx 22rpx; color: $ink-muted; font-size: 23rpx; line-height: 1.55; text-align: center; }
|
||||
.editor-field { margin-top: 18rpx; }
|
||||
.editor-field__label { display: block; margin: 0 8rpx 8rpx; color: $ink; font-size: 25rpx; font-weight: 700; }
|
||||
.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: 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: 88rpx; 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; }
|
||||
.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: 36rpx; font-weight: 700; }
|
||||
.editor-result-card__copy { display: block; margin-top: 16rpx; color: $ink-muted; font-size: 24rpx; line-height: 1.65; }
|
||||
.editor-result-card__body > .app-button { margin-top: 30rpx; }
|
||||
.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.adaptive-family-panel;
|
||||
width: 100%;
|
||||
}
|
||||
.editor-panel__body {
|
||||
padding: 38rpx 34rpx 42rpx;
|
||||
}
|
||||
.editor-eyebrow {
|
||||
display: block;
|
||||
color: $brand-red;
|
||||
font-size: 23rpx;
|
||||
letter-spacing: 3rpx;
|
||||
text-align: center;
|
||||
}
|
||||
.editor-title {
|
||||
display: block;
|
||||
margin-top: 10rpx;
|
||||
color: $ink;
|
||||
font-family: STKaiti, KaiTi, serif;
|
||||
font-size: 34rpx;
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
}
|
||||
.editor-intro {
|
||||
display: block;
|
||||
margin: 10rpx 8rpx 22rpx;
|
||||
color: $ink-muted;
|
||||
font-size: 23rpx;
|
||||
line-height: 1.55;
|
||||
text-align: center;
|
||||
}
|
||||
.editor-field {
|
||||
margin-top: 18rpx;
|
||||
}
|
||||
.editor-field__label {
|
||||
display: block;
|
||||
margin: 0 8rpx 8rpx;
|
||||
color: $ink;
|
||||
font-size: 25rpx;
|
||||
font-weight: 700;
|
||||
}
|
||||
.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: 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, 0.34);
|
||||
border-radius: 12rpx;
|
||||
background: rgba(255, 252, 245, 0.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: 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: 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;
|
||||
}
|
||||
.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: 36rpx;
|
||||
font-weight: 700;
|
||||
}
|
||||
.editor-result-card__copy {
|
||||
display: block;
|
||||
margin-top: 16rpx;
|
||||
color: $ink-muted;
|
||||
font-size: 24rpx;
|
||||
line-height: 1.65;
|
||||
}
|
||||
.editor-result-card__body > .app-button {
|
||||
margin-top: 30rpx;
|
||||
}
|
||||
</style>
|
||||
|
||||
+312
-34
@@ -2,22 +2,98 @@
|
||||
<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-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-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
|
||||
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="为家人整理一段影像" :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
|
||||
: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>
|
||||
@@ -30,9 +106,21 @@ 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 { isImagePickCancelled, pickAndUploadImage } from "@/utils/resumable-image-upload.js";
|
||||
import { goBack, handleBackPress, openPage, runBackGuard } from "@/utils/navigation.js";
|
||||
import {
|
||||
appApi,
|
||||
createRequestController,
|
||||
isRequestCancelled,
|
||||
} from "@/utils/api.js";
|
||||
import {
|
||||
isImagePickCancelled,
|
||||
pickAndUploadImage,
|
||||
} from "@/utils/resumable-image-upload.js";
|
||||
import {
|
||||
goBack,
|
||||
handleBackPress,
|
||||
openPage,
|
||||
runBackGuard,
|
||||
} from "@/utils/navigation.js";
|
||||
|
||||
const genealogyId = ref("");
|
||||
const albums = ref([]);
|
||||
@@ -49,11 +137,19 @@ const isSubmitting = ref(false);
|
||||
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 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 };
|
||||
return {
|
||||
id,
|
||||
name: text(item.albumName) || "未命名相册",
|
||||
description: text(item.albumDesc),
|
||||
photoCount: Number.isSafeInteger(item.photoCount) ? item.photoCount : 0,
|
||||
};
|
||||
};
|
||||
const loadAlbums = async () => {
|
||||
if (!hasValidContext.value) return;
|
||||
@@ -61,7 +157,9 @@ const loadAlbums = async () => {
|
||||
listState.value = "loading";
|
||||
listError.value = "";
|
||||
try {
|
||||
const rows = await appApi.getAlbums(genealogyId.value, { requestController: controller });
|
||||
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";
|
||||
@@ -71,35 +169,215 @@ const loadAlbums = async () => {
|
||||
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 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 = receipt.ossId; coverFileName.value = receipt.fileName || "封面图片"; }
|
||||
catch (error) { if (!isImagePickCancelled(error) && !isRequestCancelled(error)) uploadError.value = error?.message || "封面图片上传失败,请稍后重试。"; }
|
||||
finally { uploading.value = false; }
|
||||
try {
|
||||
const receipt = await pickAndUploadImage({ requestController: controller });
|
||||
coverOssId.value = 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 || uploading.value) return;
|
||||
if (!form.albumName.trim()) { submitError.value = "请填写相册名称"; return; }
|
||||
if (!form.albumName.trim()) {
|
||||
submitError.value = "请填写相册名称";
|
||||
return;
|
||||
}
|
||||
isSubmitting.value = true;
|
||||
submitError.value = "";
|
||||
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; }
|
||||
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 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(); });
|
||||
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(() => { active = false; controller.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, .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: 88rpx; 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; }
|
||||
.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, 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: 20rpx;
|
||||
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: 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>
|
||||
|
||||
@@ -2,14 +2,36 @@
|
||||
<template>
|
||||
<view class="album-detail-page">
|
||||
<ModulePageBackground module="family" />
|
||||
<view class="album-detail-header"><PageHeader title="相册详情" :action="valid ? '添加' : ''" custom-back @back="returnToAlbums" @action="addPhoto" /></view>
|
||||
<view class="album-detail-header"
|
||||
><PageHeader
|
||||
title="相册详情"
|
||||
:action="valid ? '添加' : ''"
|
||||
custom-back
|
||||
@back="returnToAlbums"
|
||||
@action="addPhoto"
|
||||
/></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="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-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 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>
|
||||
@@ -22,7 +44,11 @@ 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 {
|
||||
appApi,
|
||||
createRequestController,
|
||||
isRequestCancelled,
|
||||
} from "@/utils/api.js";
|
||||
import { goBack, openPage, returnTo } from "@/utils/navigation.js";
|
||||
|
||||
const genealogyId = ref("");
|
||||
@@ -31,36 +57,124 @@ 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 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 });
|
||||
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));
|
||||
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(); });
|
||||
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, .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; }
|
||||
.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>
|
||||
|
||||
@@ -2,49 +2,101 @@
|
||||
<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-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>
|
||||
<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>
|
||||
<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 = ''" />
|
||||
<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 = ''" />
|
||||
<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 = ''" />
|
||||
<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>
|
||||
<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
|
||||
mode="time"
|
||||
:value="form.shootClock"
|
||||
@change="selectShootClock"
|
||||
>
|
||||
<view :class="{ placeholder: !form.shootClock }">{{
|
||||
form.shootClock || "请选择"
|
||||
}}</view>
|
||||
</picker>
|
||||
</view>
|
||||
<view class="media-field">
|
||||
<text class="media-field__label">排序值</text>
|
||||
<input v-model="form.sortOrder" type="number" placeholder="数值越小越靠前" placeholder-class="placeholder" @input="submitError = ''" />
|
||||
<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" />
|
||||
<AppButton
|
||||
block
|
||||
:disabled="uploading || submitting"
|
||||
:label="submitting ? '正在保存…' : '保存照片'"
|
||||
@click="submitPhoto"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<view v-else class="media-state-card">
|
||||
@@ -74,10 +126,22 @@ 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 { appApi, createRequestController, isRequestCancelled } from "@/utils/api.js";
|
||||
import { isImagePickCancelled, pickAndUploadImage } from "@/utils/resumable-image-upload.js";
|
||||
import {
|
||||
appApi,
|
||||
createRequestController,
|
||||
isRequestCancelled,
|
||||
} from "@/utils/api.js";
|
||||
import {
|
||||
isImagePickCancelled,
|
||||
pickAndUploadImage,
|
||||
} from "@/utils/resumable-image-upload.js";
|
||||
import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
|
||||
import { goBack, handleBackPress, returnTo, runBackGuard } from "@/utils/navigation.js";
|
||||
import {
|
||||
goBack,
|
||||
handleBackPress,
|
||||
returnTo,
|
||||
runBackGuard,
|
||||
} from "@/utils/navigation.js";
|
||||
|
||||
const genealogyId = ref("");
|
||||
const albumId = ref("");
|
||||
@@ -87,17 +151,44 @@ const uploadError = ref("");
|
||||
const submitError = ref("");
|
||||
const uploading = ref(false);
|
||||
const submitting = ref(false);
|
||||
const form = reactive({ photoTitle: "", photoDesc: "", photographer: "", shootDate: "", shootClock: "", sortOrder: "" });
|
||||
const form = reactive({
|
||||
photoTitle: "",
|
||||
photoDesc: "",
|
||||
photographer: "",
|
||||
shootDate: "",
|
||||
shootClock: "",
|
||||
sortOrder: "",
|
||||
});
|
||||
const controller = createRequestController();
|
||||
const discardVisible = ref(false);
|
||||
const confirmation = createDiscardConfirmation((visible) => { discardVisible.value = visible; });
|
||||
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: "返回上一页" });
|
||||
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 || "");
|
||||
@@ -119,8 +210,14 @@ const selectPhoto = async () => {
|
||||
uploading.value = false;
|
||||
}
|
||||
};
|
||||
const selectShootDate = (event) => { form.shootDate = event.detail.value || ""; submitError.value = ""; };
|
||||
const selectShootClock = (event) => { form.shootClock = event.detail.value || ""; submitError.value = ""; };
|
||||
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;
|
||||
@@ -132,31 +229,43 @@ const submitPhoto = async () => {
|
||||
submitError.value = "";
|
||||
try {
|
||||
const { shootDate, shootClock, ...photoForm } = form;
|
||||
await appApi.createAlbumPhoto(genealogyId.value, albumId.value, {
|
||||
...photoForm,
|
||||
shootTime: shootTime.value,
|
||||
ossId: receipt.value.ossId,
|
||||
}, { requestController: controller });
|
||||
await appApi.createAlbumPhoto(
|
||||
genealogyId.value,
|
||||
albumId.value,
|
||||
{
|
||||
...photoForm,
|
||||
shootTime: shootTime.value,
|
||||
ossId: receipt.value.ossId,
|
||||
},
|
||||
{ requestController: controller },
|
||||
);
|
||||
pageState.value = "success";
|
||||
} catch (error) {
|
||||
if (!isRequestCancelled(error)) submitError.value = error?.message || "照片保存失败,请稍后重试";
|
||||
if (!isRequestCancelled(error))
|
||||
submitError.value = error?.message || "照片保存失败,请稍后重试";
|
||||
} finally {
|
||||
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();
|
||||
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));
|
||||
onUnmounted(() => {
|
||||
controller.abort();
|
||||
@@ -167,32 +276,156 @@ onUnmounted(() => {
|
||||
<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 { 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--picker picker { display: block; min-height: 78rpx; }
|
||||
.media-field--picker picker > view { display: flex; min-height: 78rpx; align-items: center; padding: 0 22rpx; border: 1rpx solid rgba(128, 89, 49, .34); border-radius: 12rpx; background: rgba(255, 252, 245, .7); color: $ink; font-size: 24rpx; }
|
||||
.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: 88rpx; 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__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; }
|
||||
.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.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--picker picker {
|
||||
display: block;
|
||||
min-height: 78rpx;
|
||||
}
|
||||
.media-field--picker picker > view {
|
||||
display: flex;
|
||||
min-height: 78rpx;
|
||||
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: 24rpx;
|
||||
}
|
||||
.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: 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__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>
|
||||
|
||||
@@ -1,11 +1,73 @@
|
||||
<!-- 页面编号:F-10;用途:家族视频。当前缺读取、发布和播放业务 owner。 -->
|
||||
<template>
|
||||
<view class="video-page"><ModulePageBackground module="family" /><view class="page-header"><PageHeader title="家族视频" custom-back @back="returnToFamily" /></view><view class="page-content"><view class="state-card"><text>家族视频暂未开放</text><text>视频功能正在准备中。开放后,家人的影像会在这里展示。</text><AppButton block :label="hasValidContext ? '返回家族动态' : '返回上一页'" @click="returnToFamily" /></view></view></view>
|
||||
<view class="video-page"
|
||||
><ModulePageBackground module="family" /><view class="page-header"
|
||||
><PageHeader title="家族视频" custom-back @back="returnToFamily" /></view
|
||||
><view class="page-content"
|
||||
><view class="state-card"
|
||||
><text>家族视频暂未开放</text
|
||||
><text>视频功能正在准备中。开放后,家人的影像会在这里展示。</text
|
||||
><AppButton
|
||||
block
|
||||
:label="hasValidContext ? '返回家族动态' : '返回上一页'"
|
||||
@click="returnToFamily" /></view></view
|
||||
></view>
|
||||
</template>
|
||||
<script setup>
|
||||
import { computed,ref } from "vue"; import { onLoad } from "@dcloudio/uni-app"; import AppButton from "@/components/AppButton.vue"; import ModulePageBackground from "@/components/ModulePageBackground.vue"; import PageHeader from "@/components/PageHeader.vue"; import { goBack,returnTo } from "@/utils/navigation.js"; const genealogyId=ref("");const hasValidContext=computed(()=>/^[1-9]\d*$/.test(genealogyId.value));onLoad((query)=>{genealogyId.value=String(query?.genealogyId||"");});const returnToFamily=()=>hasValidContext.value?returnTo("F01",{genealogyId:genealogyId.value}):goBack();
|
||||
import { computed, ref } from "vue";
|
||||
import { onLoad } from "@dcloudio/uni-app";
|
||||
import AppButton from "@/components/AppButton.vue";
|
||||
import ModulePageBackground from "@/components/ModulePageBackground.vue";
|
||||
import PageHeader from "@/components/PageHeader.vue";
|
||||
import { goBack, returnTo } from "@/utils/navigation.js";
|
||||
const genealogyId = ref("");
|
||||
const hasValidContext = computed(() => /^[1-9]\d*$/.test(genealogyId.value));
|
||||
onLoad((query) => {
|
||||
genealogyId.value = String(query?.genealogyId || "");
|
||||
});
|
||||
const returnToFamily = () =>
|
||||
hasValidContext.value
|
||||
? returnTo("F01", { genealogyId: genealogyId.value })
|
||||
: goBack();
|
||||
</script>
|
||||
<style scoped lang="scss">
|
||||
@use "../../styles/adaptive-frame-profiles.scss" as adaptive;
|
||||
.video-page{display:flex;min-height:100vh;flex-direction:column;background:$paper}.page-header,.page-content{z-index:1}.page-content{padding:18rpx 24rpx 72rpx}.state-card{box-sizing:border-box;min-height:340rpx;padding:78rpx 52rpx 50rpx;text-align:center;@include adaptive.adaptive-family-content}.state-card text{display:block}.state-card text:first-child{color:$ink;font-family:STKaiti,KaiTi,serif;font-size:35rpx;font-weight:700}.state-card text:nth-child(2){margin-top:18rpx;color:$ink-muted;font-size:24rpx;line-height:1.65}.state-card .app-button{margin-top:28rpx}
|
||||
.video-page {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
flex-direction: column;
|
||||
background: $paper;
|
||||
}
|
||||
.page-header,
|
||||
.page-content {
|
||||
z-index: 1;
|
||||
}
|
||||
.page-content {
|
||||
padding: 18rpx 24rpx 72rpx;
|
||||
}
|
||||
.state-card {
|
||||
box-sizing: border-box;
|
||||
min-height: 340rpx;
|
||||
padding: 78rpx 52rpx 50rpx;
|
||||
text-align: center;
|
||||
@include adaptive.adaptive-family-content;
|
||||
}
|
||||
.state-card text {
|
||||
display: block;
|
||||
}
|
||||
.state-card text:first-child {
|
||||
color: $ink;
|
||||
font-family: STKaiti, KaiTi, serif;
|
||||
font-size: 35rpx;
|
||||
font-weight: 700;
|
||||
}
|
||||
.state-card text:nth-child(2) {
|
||||
margin-top: 18rpx;
|
||||
color: $ink-muted;
|
||||
font-size: 24rpx;
|
||||
line-height: 1.65;
|
||||
}
|
||||
.state-card .app-button {
|
||||
margin-top: 28rpx;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user