470 lines
12 KiB
Vue
470 lines
12 KiB
Vue
<template>
|
|
<div v-can="pageAccess" class="store-diary-edit">
|
|
<t-form class="form" label-align="left" label-width="5rem" v-entrance-transition>
|
|
<t-form-item label="日记标题" required-mark>
|
|
<t-input
|
|
v-model.trim="article.title"
|
|
borderless
|
|
maxlength="100"
|
|
placeholder="请输入日记标题"
|
|
/>
|
|
</t-form-item>
|
|
<t-form-item label="日记摘要">
|
|
<t-textarea
|
|
v-model.trim="article.description"
|
|
borderless
|
|
maxlength="200"
|
|
autosize
|
|
placeholder="可填写本篇日记摘要"
|
|
/>
|
|
</t-form-item>
|
|
<section class="photo-wrapper" v-entrance-transition="140">
|
|
<media-selector
|
|
v-model="photoList"
|
|
v-model:photo-items="photoItemList"
|
|
title="附加照片"
|
|
:edit-mode="true"
|
|
:loading="isPhotoUploading"
|
|
:show-type-label="false"
|
|
:show-viewer-actions="false"
|
|
@add="openPhotoPicker"
|
|
@capture-photo="capturePhoto"
|
|
@capture-video="captureVideo"
|
|
/>
|
|
<input
|
|
ref="photoInputRef"
|
|
class="file-input"
|
|
type="file"
|
|
accept="*/*"
|
|
multiple
|
|
@change="handlePhotoSelected"
|
|
>
|
|
<input
|
|
ref="photoCameraInputRef"
|
|
class="file-input"
|
|
type="file"
|
|
accept="image/*"
|
|
capture="environment"
|
|
@change="handlePhotoSelected"
|
|
>
|
|
<input
|
|
ref="videoInputRef"
|
|
class="file-input"
|
|
type="file"
|
|
accept="video/*"
|
|
capture="environment"
|
|
@change="handlePhotoSelected"
|
|
>
|
|
</section>
|
|
<section class="editor-wrapper" v-entrance-transition="70">
|
|
<article-editor
|
|
ref="articleEditorRef"
|
|
:model-value="article"
|
|
class="editor"
|
|
placeholder="请输入门店日记正文,可插入图片或视频"
|
|
:upload-image="uploadImage"
|
|
:upload-video="uploadVideo"
|
|
:resolve-attachment-url="resolveAttachmentUrl"
|
|
@update:model-value="updateArticle"
|
|
/>
|
|
</section>
|
|
</t-form>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import {
|
|
ArticleEditor,
|
|
type Article as ArticleEditorArticle,
|
|
type ArticleEditorExpose,
|
|
type UploadResult
|
|
} from "timi-tdesign-mobile";
|
|
import { AttachmentAPI, type Attachment, useUserStore } from "timi-web-api";
|
|
import StoreDiaryAPI from "@/api/StoreDiaryAPI";
|
|
import MediaSelector from "@/components/mediaSelector/MediaSelector.vue";
|
|
import { useCurrentStoreStore } from "@/store/currentStoreStore";
|
|
import { useNavBarStore } from "@/store/navBarStore";
|
|
import type { StoreDiary } from "@/types/StoreDiary";
|
|
import type { PhotoItem } from "@/types/Photo";
|
|
import { getErrorMessage } from "@/utils/error";
|
|
import { compressUploadImage, compressUploadImageList } from "@/utils/imageCompression";
|
|
import { getAttachmentReadURL } from "@/utils/attachment";
|
|
import { Message } from "@/utils/message";
|
|
import { permissionCodes } from "@/utils/permission";
|
|
import { saveCapturedFilesFromInput } from "@/utils/twaBridge";
|
|
|
|
defineOptions({
|
|
name: "StoreDiaryEdit"
|
|
});
|
|
|
|
const route = useRoute();
|
|
const router = useRouter();
|
|
const navBarStore = useNavBarStore();
|
|
const userStore = useUserStore();
|
|
const currentStoreStore = useCurrentStoreStore();
|
|
const routeId = computed(() => String(route.params.id || ""));
|
|
const isCreateMode = computed(() => !routeId.value);
|
|
const savePermissionCode = computed(() => isCreateMode.value ? permissionCodes.storeDiary.create : permissionCodes.storeDiary.update);
|
|
const pageAccess = computed(() => ({
|
|
value: [permissionCodes.storeDiary.read, savePermissionCode.value],
|
|
mode: "and" as const
|
|
}));
|
|
|
|
// ---------- 日记表单 ----------
|
|
|
|
const isSaving = ref(false);
|
|
const articleEditorRef = ref<ArticleEditorExpose>();
|
|
const article = ref<StoreDiary>(createEmptyArticle());
|
|
|
|
function createEmptyArticle(): StoreDiary {
|
|
return {
|
|
bizType: "GAO_STORE_DIARY",
|
|
contentType: "TIP_TAP",
|
|
content: "",
|
|
title: "",
|
|
description: "",
|
|
attachmentList: [],
|
|
status: "NORMAL"
|
|
};
|
|
}
|
|
|
|
function normalizeContentType(value?: string): StoreDiary["contentType"] {
|
|
if (value === "TEXT" || value === "MARKDOWN" || value === "TIP_TAP") {
|
|
return value;
|
|
}
|
|
return "TIP_TAP";
|
|
}
|
|
|
|
function updateArticle(value: ArticleEditorArticle): void {
|
|
article.value = {
|
|
...article.value,
|
|
...value,
|
|
contentType: normalizeContentType(value.contentType)
|
|
};
|
|
}
|
|
|
|
function getCreateTitle(): string {
|
|
const title = route.query.title;
|
|
return Array.isArray(title) ? String(title[0] || "") : String(title || "");
|
|
}
|
|
|
|
// ---------- 附加照片 ----------
|
|
|
|
const photoInputRef = ref<HTMLInputElement>();
|
|
const photoCameraInputRef = ref<HTMLInputElement>();
|
|
const videoInputRef = ref<HTMLInputElement>();
|
|
const photoItemList = ref<PhotoItem[]>([]);
|
|
const isPhotoUploading = ref(false);
|
|
const photoList = computed<string[]>({
|
|
get: () => photoItemList.value.map(item => item.url),
|
|
set: value => {
|
|
const urlSet = new Set(value);
|
|
photoItemList.value = photoItemList.value.filter(item => urlSet.has(item.url));
|
|
}
|
|
});
|
|
|
|
function openPhotoPicker(): void {
|
|
photoInputRef.value?.click();
|
|
}
|
|
|
|
function capturePhoto(): void {
|
|
photoCameraInputRef.value?.click();
|
|
}
|
|
|
|
function captureVideo(): void {
|
|
videoInputRef.value?.click();
|
|
}
|
|
|
|
async function handlePhotoSelected(event: Event): Promise<void> {
|
|
const input = event.target as HTMLInputElement;
|
|
const fileList = Array.from(input.files || []);
|
|
if (!fileList.length) {
|
|
return;
|
|
}
|
|
saveCapturedFilesFromInput(input, fileList);
|
|
|
|
isPhotoUploading.value = true;
|
|
try {
|
|
const tempFileList = await AttachmentAPI.upload(await compressUploadImageList(fileList));
|
|
for (const [index, tempFile] of tempFileList.entries()) {
|
|
if (!tempFile.id) {
|
|
continue;
|
|
}
|
|
photoItemList.value.push({
|
|
mimeType: fileList[index]?.type,
|
|
tempFileId: tempFile.id,
|
|
attachType: "ADDITIONAL",
|
|
url: AttachmentAPI.getTempReadURL(tempFile.id)
|
|
});
|
|
}
|
|
} catch (error) {
|
|
Message.error(getErrorMessage(error, "附加照片上传失败"));
|
|
} finally {
|
|
isPhotoUploading.value = false;
|
|
input.value = "";
|
|
}
|
|
}
|
|
|
|
function buildPhotoItemList(attachmentList?: StoreDiary["attachmentList"]): PhotoItem[] {
|
|
return (attachmentList || []).flatMap(item => {
|
|
if (item.attachType !== "ADDITIONAL" || !item.id) {
|
|
return [];
|
|
}
|
|
return [{
|
|
attachment: item as Partial<Attachment>,
|
|
attachType: "ADDITIONAL",
|
|
mimeType: typeof item.mimeType === "string" ? item.mimeType : undefined,
|
|
previewUrl: getAttachmentReadURL(item.id),
|
|
url: getAttachmentReadURL(item.id, 90)
|
|
}];
|
|
});
|
|
}
|
|
|
|
function buildAdditionalAttachmentList(): Attachment[] {
|
|
return photoItemList.value.flatMap(item => {
|
|
if (item.attachment) {
|
|
return [{
|
|
...item.attachment,
|
|
attachType: "ADDITIONAL"
|
|
}];
|
|
}
|
|
if (item.tempFileId) {
|
|
return [{
|
|
attachType: "ADDITIONAL",
|
|
tempFileId: item.tempFileId
|
|
}];
|
|
}
|
|
return [];
|
|
});
|
|
}
|
|
|
|
// ---------- 媒体上传 ----------
|
|
|
|
async function uploadMedia(file: File): Promise<UploadResult> {
|
|
const [tempFile] = await AttachmentAPI.upload(file);
|
|
if (!tempFile?.id) {
|
|
throw new Error("媒体上传失败");
|
|
}
|
|
|
|
return {
|
|
id: tempFile.id,
|
|
url: AttachmentAPI.getTempReadURL(tempFile.id),
|
|
name: file.name
|
|
};
|
|
}
|
|
|
|
async function uploadImage(file: File): Promise<UploadResult> {
|
|
return await uploadMedia(await compressUploadImage(file));
|
|
}
|
|
|
|
async function uploadVideo(file: File): Promise<UploadResult> {
|
|
return await uploadMedia(file);
|
|
}
|
|
|
|
function resolveAttachmentUrl(attachmentId: string): string {
|
|
return getAttachmentReadURL(attachmentId);
|
|
}
|
|
|
|
// ---------- 保存 ----------
|
|
|
|
function getContentText(content: string): string {
|
|
const parser = new DOMParser();
|
|
return parser.parseFromString(content, "text/html").body.textContent?.trim() || "";
|
|
}
|
|
|
|
function hasMediaContent(content: string): boolean {
|
|
const parser = new DOMParser();
|
|
return Boolean(parser.parseFromString(content, "text/html").querySelector("img, video"));
|
|
}
|
|
|
|
function validateArticle(value: StoreDiary): boolean {
|
|
if (!value.bizId) {
|
|
Message.warning("请选择门店");
|
|
return false;
|
|
}
|
|
if (!value.title?.trim()) {
|
|
Message.warning("请输入日记标题");
|
|
return false;
|
|
}
|
|
|
|
const content = value.content || "";
|
|
if (!getContentText(content) && !hasMediaContent(content)) {
|
|
Message.warning("请输入日记正文");
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
async function save(): Promise<void> {
|
|
if (isSaving.value) {
|
|
return;
|
|
}
|
|
if (!article.value.title?.trim()) {
|
|
Message.warning("请输入日记标题");
|
|
return;
|
|
}
|
|
|
|
isSaving.value = true;
|
|
try {
|
|
const editorValue = await articleEditorRef.value?.submit();
|
|
const value: StoreDiary = {
|
|
...article.value,
|
|
...(editorValue || {}),
|
|
title: article.value.title?.trim() || "",
|
|
description: article.value.description?.trim() || "",
|
|
contentType: normalizeContentType(editorValue?.contentType ?? article.value.contentType),
|
|
content: editorValue?.content ?? article.value.content ?? "",
|
|
attachmentList: [
|
|
...(editorValue?.attachmentList ?? article.value.attachmentList ?? []).filter(item => item.attachType !== "ADDITIONAL"),
|
|
...buildAdditionalAttachmentList()
|
|
],
|
|
status: article.value.status || "NORMAL"
|
|
};
|
|
if (!validateArticle(value)) {
|
|
return;
|
|
}
|
|
|
|
article.value = value;
|
|
if (isCreateMode.value) {
|
|
const id = await StoreDiaryAPI.create(value);
|
|
Message.success("门店日记已创建");
|
|
await router.replace({
|
|
name: "StoreDiaryDetail",
|
|
params: { id }
|
|
});
|
|
return;
|
|
}
|
|
|
|
await StoreDiaryAPI.update(value);
|
|
Message.success("门店日记已保存");
|
|
await router.replace({
|
|
name: "StoreDiaryDetail",
|
|
params: { id: routeId.value }
|
|
});
|
|
} catch (error) {
|
|
Message.error(getErrorMessage(error, "门店日记保存失败"));
|
|
} finally {
|
|
isSaving.value = false;
|
|
}
|
|
}
|
|
|
|
// ---------- 页面数据 ----------
|
|
|
|
async function prepareStoreScope(): Promise<void> {
|
|
const store = await currentStoreStore.loadLoginStore();
|
|
if (store?.id) {
|
|
article.value.bizId = store.id;
|
|
}
|
|
}
|
|
|
|
async function loadArticle(): Promise<void> {
|
|
await prepareStoreScope();
|
|
if (isCreateMode.value) {
|
|
article.value = {
|
|
...createEmptyArticle(),
|
|
title: getCreateTitle(),
|
|
bizId: article.value.bizId || currentStoreStore.currentStoreId
|
|
};
|
|
photoItemList.value = [];
|
|
return;
|
|
}
|
|
|
|
const result = await StoreDiaryAPI.detail(routeId.value);
|
|
article.value = {
|
|
...result,
|
|
title: result.title || "",
|
|
description: result.description || "",
|
|
contentType: result.contentType || "TIP_TAP",
|
|
content: result.content || "",
|
|
attachmentList: result.attachmentList || []
|
|
};
|
|
photoItemList.value = buildPhotoItemList(result.attachmentList);
|
|
}
|
|
|
|
onMounted(async () => {
|
|
if (userStore.hasPermissions(savePermissionCode.value)) {
|
|
navBarStore.setRightText("保存", "var(--td-brand-color)", save);
|
|
}
|
|
try {
|
|
await loadArticle();
|
|
} catch (error) {
|
|
Message.error(getErrorMessage(error, "门店日记加载失败"));
|
|
}
|
|
});
|
|
|
|
onUnmounted(() => navBarStore.clearRight());
|
|
</script>
|
|
|
|
<style scoped lang="less">
|
|
.store-diary-edit {
|
|
min-height: 100%;
|
|
padding-top: var(--app-nav-height);
|
|
|
|
.form {
|
|
margin: 0;
|
|
border-radius: 0;
|
|
|
|
.picker-text {
|
|
width: 100%;
|
|
text-align: right;
|
|
}
|
|
|
|
.editor-wrapper {
|
|
width: 100%;
|
|
overflow: visible;
|
|
min-width: 0;
|
|
max-width: 100%;
|
|
border-top: 1px solid var(--td-component-border);
|
|
|
|
.editor {
|
|
width: 100%;
|
|
min-width: 0;
|
|
max-width: 100%;
|
|
border: none;
|
|
border-radius: 0;
|
|
}
|
|
|
|
:deep(.tp-editor) {
|
|
width: 100%;
|
|
height: auto;
|
|
min-width: 0;
|
|
max-width: 100%;
|
|
overflow: visible;
|
|
}
|
|
|
|
:deep(.content) {
|
|
padding: 0;
|
|
overflow: visible;
|
|
|
|
.value {
|
|
border-radius: 0;
|
|
}
|
|
}
|
|
|
|
:deep(.toolbar) {
|
|
top: var(--app-nav-height);
|
|
width: 100%;
|
|
z-index: 10;
|
|
position: sticky;
|
|
min-width: 0;
|
|
max-width: 100%;
|
|
}
|
|
|
|
:deep(.toolbar .scroll) {
|
|
width: 100%;
|
|
min-width: 0;
|
|
max-width: 100%;
|
|
}
|
|
}
|
|
|
|
.photo-wrapper {
|
|
padding: 1rem;
|
|
|
|
.file-input {
|
|
display: none;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
</style>
|