修改完成待测试

This commit is contained in:
2026-09-13 17:45:47 +08:00
parent 9ad572907b
commit bbc024f7f2
80 changed files with 3844 additions and 1672 deletions
+187 -8
View File
@@ -94,6 +94,35 @@
/>
</view>
</view>
<view class="editor-field editor-field--media">
<view>
<text class="editor-field__label">正文图片</text>
<text class="editor-field__hint">图片按添加顺序显示可单独移除</text>
</view>
<button
class="upload-button"
:disabled="uploading || isSubmitting"
@click="uploadArticleMedia"
>
{{ uploading ? "上传中…" : "添加正文图片" }}
</button>
<view v-if="mediaReceipts.length" class="body-media-list">
<view
v-for="(receipt, index) in mediaReceipts"
:key="`${receipt.ossId}-${index}`"
class="body-media-item"
>
<text>{{ index + 1 }}. {{ receipt.fileName || "正文图片" }}</text>
<button
class="remove-media-button"
:disabled="uploading || isSubmitting"
:aria-label="`移除第${index + 1}张正文图片`"
@click="removeArticleMedia(index)"
>移除</button>
</view>
</view>
<text v-if="mediaUploadError" class="editor-save-error">{{ mediaUploadError }}</text>
</view>
<view class="editor-field">
<text class="editor-field__label">作者名称</text>
<view class="editor-control"
@@ -104,6 +133,30 @@
@input="submitError = ''"
/></view>
</view>
<view v-if="!isEdit" class="editor-field">
<text class="editor-field__label">内容密码选填</text>
<text class="editor-field__hint">填写后会先创建谱文再立即启用密码保护密码设置失败时会明确提示</text>
<view class="editor-control">
<input
v-model="contentPassword"
password
maxlength="128"
placeholder="请输入8至128位内容密码"
placeholder-class="editor-placeholder"
@input="submitError = ''"
/>
</view>
<view class="editor-control editor-control--password-confirm">
<input
v-model="contentPasswordConfirm"
password
maxlength="128"
placeholder="请再次输入内容密码"
placeholder-class="editor-placeholder"
@input="submitError = ''"
/>
</view>
</view>
<text v-if="submitError" class="editor-save-error">{{
submitError
}}</text>
@@ -178,8 +231,14 @@ const uploading = ref(false);
const discardVisible = ref(false);
const submitError = ref("");
const uploadError = ref("");
const mediaUploadError = ref("");
const coverOssId = ref(null);
const coverFileName = ref("");
const mediaReceipts = ref([]);
const contentPassword = ref("");
const contentPasswordConfirm = ref("");
const passwordSetupError = ref("");
const passwordProtectionEnabled = ref(false);
const categoryOptionsState = ref("loading");
const categoryOptions = ref([]);
const preservedUpdateFields = ref({ sortOrder: null, status: "" });
@@ -194,7 +253,9 @@ const form = reactive({
const articleDetailController = createRequestController();
const articleCategoryController = createRequestController();
const articleCoverUploadController = createRequestController();
const articleMediaUploadController = createRequestController();
const articleSaveController = createRequestController();
const articlePasswordController = createRequestController();
const articleCreateGuard = createNonIdempotentWriteGuard();
let pageActive = true;
const categoryOptionLabels = computed(() => ["不设置分类", ...categoryOptions.value.map((item) => item.name)]);
@@ -204,13 +265,20 @@ const categoryOptionIndex = computed(() => {
});
const categoryOptionLabel = computed(() => categoryOptionLabels.value[categoryOptionIndex.value] || "不设置分类");
const isEdit = computed(() => mode.value === "edit");
const mediaOssIds = computed(() => mediaReceipts.value.map((item) => item.ossId).join(","));
const formSnapshot = computed(() =>
JSON.stringify({ ...form, coverOssId: coverOssId.value || "" }),
JSON.stringify({ ...form, coverOssId: coverOssId.value || "", mediaOssIds: mediaOssIds.value }),
);
const isDirty = computed(() =>
isEdit.value
? Boolean(formBaseline.value) && formSnapshot.value !== formBaseline.value
: Boolean(Object.values(form).some((value) => value.trim()) || coverOssId.value),
: Boolean(
Object.values(form).some((value) => value.trim()) ||
coverOssId.value ||
mediaReceipts.value.length ||
contentPassword.value ||
contentPasswordConfirm.value
),
);
const hasValidContext = computed(
() =>
@@ -219,10 +287,19 @@ const hasValidContext = computed(
);
const resultCopy = computed(() =>
editorState.value === "success"
? {
? passwordSetupError.value
? {
eyebrow: "谱文已创建",
title: "密码保护尚未确认",
copy: passwordSetupError.value,
action: "打开谱文详情",
}
: {
eyebrow: "保存成功",
title: isEdit.value ? "谱文已更新" : "谱文已提交",
copy: "已保存,返回后会显示最新内容。",
copy: !isEdit.value && passwordProtectionEnabled.value
? "谱文已保存,并已启用内容密码。"
: "已保存,返回后会显示最新内容。",
action: isEdit.value ? "返回谱文详情" : "返回谱文列表",
}
: editorState.value === "error"
@@ -313,6 +390,10 @@ const loadArticleForEdit = async () => {
coverFileName.value = article.coverFile
? article.coverFile.fileName || "当前封面图片"
: "";
mediaReceipts.value = article.mediaFiles.map((file) => ({
ossId: file.ossId,
fileName: file.fileName || "正文图片",
}));
preservedUpdateFields.value = {
sortOrder: article.sortOrder,
status: article.status,
@@ -352,6 +433,35 @@ const clearCover = () => {
uploadError.value = "";
};
const uploadArticleMedia = async () => {
if (uploading.value || isSubmitting.value) return;
uploading.value = true;
mediaUploadError.value = "";
try {
const receipt = await pickAndUploadImage({
requestController: articleMediaUploadController,
});
if (!pageActive) return;
if (mediaReceipts.value.some((item) => item.ossId === receipt.ossId)) {
mediaUploadError.value = "这张图片已经添加。";
return;
}
mediaReceipts.value = [...mediaReceipts.value, receipt];
} catch (error) {
if (pageActive && !isImagePickCancelled(error) && !isRequestCancelled(error)) {
mediaUploadError.value = getRequestErrorMessage(error, "正文图片上传失败,请稍后重试。");
}
} finally {
if (pageActive) uploading.value = false;
}
};
const removeArticleMedia = (index) => {
if (uploading.value || isSubmitting.value || !Number.isInteger(index)) return;
mediaReceipts.value = mediaReceipts.value.filter((_, mediaIndex) => mediaIndex !== index);
mediaUploadError.value = "";
};
const saveArticle = async () => {
if (isSubmitting.value || uploading.value || !hasValidContext.value) return;
if (!form.articleTitle.trim() || !form.articleContent.trim()) {
@@ -363,6 +473,7 @@ const saveArticle = async () => {
const payload = {
...form,
coverOssId: coverOssId.value,
mediaOssIds: mediaOssIds.value,
...(isEdit.value ? preservedUpdateFields.value : {}),
};
const createAttempt = isEdit.value ? null : articleCreateGuard.begin(payload);
@@ -371,18 +482,48 @@ const saveArticle = async () => {
"上次提交结果暂时无法确认,请先返回谱文列表检查,避免重复创建。";
return;
}
if (contentPassword.value || contentPasswordConfirm.value) {
if (contentPassword.value.length < 8 || contentPassword.value.length > 128) {
submitError.value = "内容密码必须为8至128位。";
return;
}
if (contentPassword.value !== contentPasswordConfirm.value) {
submitError.value = "两次输入的内容密码不一致。";
return;
}
}
isSubmitting.value = true;
submitError.value = "";
passwordSetupError.value = "";
try {
if (isEdit.value) {
await familyArticleApi.updateArticle(genealogyId.value, articleId.value, payload, {
requestController: articleSaveController,
});
} else {
await familyArticleApi.createArticle(genealogyId.value, payload, {
const createdArticle = await familyArticleApi.createArticle(genealogyId.value, payload, {
requestController: articleSaveController,
});
if (!pageActive) return;
articleId.value = createdArticle.id;
if (contentPassword.value) {
try {
await familyArticleApi.setArticlePassword(
genealogyId.value,
createdArticle.id,
contentPassword.value,
{ requestController: articlePasswordController },
);
passwordProtectionEnabled.value = true;
} catch (passwordError) {
if (!pageActive || isRequestCancelled(passwordError)) return;
const failureCopy = getRequestErrorMessage(passwordError, "内容密码设置失败");
passwordSetupError.value = `谱文已经创建,但${failureCopy}。请进入详情重新设置;当前内容可能尚未受到密码保护。`;
}
contentPassword.value = "";
contentPasswordConfirm.value = "";
}
}
if (!pageActive) return;
editorState.value = "success";
@@ -412,7 +553,7 @@ const requestBack = () =>
});
const handleResultAction = () =>
editorState.value === "success"
? isEdit.value
? isEdit.value || passwordSetupError.value
? returnTo("F05", { genealogyId: genealogyId.value, articleId: articleId.value })
: returnTo("F04", { genealogyId: genealogyId.value })
: isEdit.value
@@ -424,7 +565,9 @@ onUnload(() => {
articleDetailController.abort();
articleCategoryController.abort();
articleCoverUploadController.abort();
articleMediaUploadController.abort();
articleSaveController.abort();
articlePasswordController.abort();
discardConfirmation.dispose();
});
</script>
@@ -532,7 +675,8 @@ onUnload(() => {
.editor-control--article textarea {
min-height: 180rpx;
}
.editor-field--cover {
.editor-field--cover,
.editor-field--media {
display: grid;
gap: 12rpx;
padding: 18rpx 22rpx;
@@ -540,7 +684,8 @@ onUnload(() => {
border-radius: 12rpx;
background: rgba(255, 252, 245, 0.7);
}
.editor-field--cover .editor-field__label {
.editor-field--cover .editor-field__label,
.editor-field--media .editor-field__label {
margin: 0;
}
.editor-field__hint {
@@ -565,6 +710,7 @@ onUnload(() => {
font-size: clamp(13px, 21rpx, 16px);
overflow-wrap: anywhere;
}
.editor-control--password-confirm { margin-top: 14rpx; }
.remove-cover-button {
justify-self: start;
min-height: 72rpx;
@@ -577,6 +723,39 @@ onUnload(() => {
font-size: clamp(13px, 21rpx, 16px);
}
.remove-cover-button::after { border: 0; }
.body-media-list {
display: grid;
gap: 8rpx;
}
.body-media-item {
display: flex;
min-height: 66rpx;
align-items: center;
justify-content: space-between;
gap: 16rpx;
padding: 8rpx 12rpx 8rpx 18rpx;
border: 1rpx solid rgba(128, 89, 49, 0.22);
border-radius: 8rpx;
background: rgba(255, 253, 248, 0.58);
}
.body-media-item > text {
min-width: 0;
flex: 1;
color: $ink-muted;
font-size: clamp(13px, 21rpx, 16px);
overflow-wrap: anywhere;
}
.remove-media-button {
min-width: var(--app-touch-min);
min-height: var(--app-touch-min);
margin: 0;
padding: 0 14rpx;
border: 0;
background: transparent;
color: $brand-red;
font-size: clamp(13px, 20rpx, 16px);
}
.remove-media-button::after { border: 0; }
.editor-placeholder {
color: #9e8e79;
}