Files
YschcmsFront/src/views/cms/ArticleEdit.vue
T

342 lines
13 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, reactive, ref, shallowRef, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import { Message } from "view-ui-plus";
import { Editor, Toolbar } from "@wangeditor/editor-for-vue";
import "@wangeditor/editor/dist/css/style.css";
import { articleApi, categoryApi, uploadUrl } from "@/api/module/cms";
import { useCmsStore } from "@/store";
import config from "@/assets/config/host.config";
import { portalUrlOf } from "@/utils/sitePath";
import { CategoryTreeNode } from "@/types/cms";
const route = useRoute();
const router = useRouter();
const store = useCmsStore();
const editId = computed(() => {
const raw = route.params.id || route.query.id;
return raw ? parseInt(String(raw)) : undefined;
});
const formRef = ref();
const saving = ref(false);
const editorLoading = ref(true);
const uploadedIds = ref<number[]>([]);
const form = reactive({
categoryId: undefined as number | undefined,
title: "",
summary: "",
content: "",
coverImage: "",
authorName: "",
source: "",
topmost: 0,
publishTime: "" as string | "",
offlineTime: "" as string | "",
syncWechat: false,
});
const rules = {
title: [{ required: true, message: "标题不得为空", trigger: "blur" }],
categoryId: [{ required: true, type: "number", message: "请选择所属栏目", trigger: "change" }],
};
/** 栏目下拉(树拍平缩进) */
const categoryOptions = ref<{ id: number; label: string }[]>([]);
function flattenCategories(tree: CategoryTreeNode[], depth = 0): { id: number; label: string }[] {
const list: { id: number; label: string }[] = [];
tree.forEach(node => {
list.push({ id: node.id!, label: `${" ".repeat(depth)}${node.name}` });
if (node.children?.length) list.push(...flattenCategories(node.children, depth + 1));
});
return list;
}
/* ===== wangEditor(Notice 已趟坑配置:无视频组、自定义图片上传) ===== */
const editorRef = shallowRef();
const toolbarConfig = { excludeKeys: ["group-video", "fullScreen"] };
const serviceBase = config.isMock ? "" : config.serviceHost;
const editorConfig = {
placeholder: "请输入正文……",
MENU_CONF: {
uploadImage: {
async customUpload(file: File, insertFn: (url: string, alt: string, href: string) => void) {
const fd = new FormData();
fd.append("file", file);
fd.append("siteId", String(store.siteId));
if (editId.value) fd.append("articleId", String(editId.value));
try {
const resp = await fetch(`${serviceBase}/api/attachment/upload`, {
method: "POST",
body: fd,
credentials: "include",
});
const res = await resp.json();
if (res.code === 0 && res.data) {
uploadedIds.value.push(res.data.id);
insertFn(res.data.url, res.data.fileName || "", res.data.url);
} else {
Message.error(res.msg || "图片上传失败");
}
} catch (e) {
Message.error("图片上传失败");
}
},
},
},
};
function handleCreated(editor: any) {
editorRef.value = editor;
editorLoading.value = false;
}
onBeforeUnmount(() => {
editorRef.value?.destroy();
});
/* ===== 封面上传 ===== */
function coverUploaded(res: any) {
if (res.code === 0 && res.data) {
uploadedIds.value.push(res.data.id);
form.coverImage = res.data.url;
} else {
Message.error(res.msg || "封面上传失败");
}
}
function coverError(_err: any) {
Message.error("封面上传失败");
}
async function load() {
if (!store.siteId) return;
const tree = await categoryApi.tree(store.siteId).send();
categoryOptions.value = flattenCategories(tree || []);
if (!editId.value) {
// B5:栏目工作台「新建文章」带 categoryId 预选(仅新建态;编辑态以详情回填为准)
const preselect = route.query.categoryId ? parseInt(String(route.query.categoryId)) : undefined;
if (preselect && categoryOptions.value.some(o => o.id === preselect)) {
form.categoryId = preselect;
}
return;
}
const detail = await articleApi.get(store.siteId, editId.value).send();
if (!detail) return;
Object.assign(form, {
categoryId: detail.categoryId,
title: detail.title,
summary: detail.summary || "",
content: detail.content || "",
coverImage: detail.coverImage || "",
authorName: detail.authorName || "",
source: detail.source || "",
topmost: detail.topmost || 0,
publishTime: detail.publishTime || "",
offlineTime: detail.offlineTime || "",
});
}
/** 时间统一为后端 LocalDateTime 契约格式 yyyy-MM-dd HH:mm:ss
* (DatePicker v-model 产出 Date 对象,JSON 序列化成 ISO UTC 串后端无法反序列化) */
function fmtDateTime(v: string | Date | undefined): string | undefined {
if (!v) return undefined;
let d: Date;
if (v instanceof Date) {
d = v;
} else if (/\d{4}-\d{2}-\d{2} \d{2}:\d{2}/.test(v)) {
return v; // 已是契约格式(编辑回显)
} else {
d = new Date(v); // ISO 等其他串
if (isNaN(d.getTime())) return undefined;
}
const p = (n: number) => String(n).padStart(2, "0");
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`;
}
/** 组装保存入参 */
function buildRequest() {
return {
id: editId.value,
siteId: store.siteId!,
categoryId: form.categoryId!,
title: form.title,
summary: form.summary || undefined,
content: form.content,
coverImage: form.coverImage || undefined,
authorName: form.authorName || undefined,
source: form.source || undefined,
topmost: form.topmost,
publishTime: fmtDateTime(form.publishTime),
offlineTime: fmtDateTime(form.offlineTime),
syncWechat: false,
attachmentIds: uploadedIds.value,
};
}
async function save(): Promise<number | undefined> {
const valid = await formRef.value?.validate();
if (!valid) return undefined;
saving.value = true;
try {
return await articleApi.save(buildRequest()).send();
} finally {
saving.value = false;
}
}
async function saveDraft() {
const id = await save();
if (id) {
Message.success("保存成功!");
const code = store.schoolCode;
router.replace(code ? `/sch/${code}/article/edit/${id}` : "/workbench");
}
}
async function saveAndPublish() {
const id = await save();
if (!id) return;
await articleApi.publish(store.siteId!, id).send();
Message.success("已提交发布!");
const code = store.schoolCode;
router.replace(code ? `/sch/${code}/article` : "/workbench");
}
/** 预览:仅已发布文章在门户可见 */
function preview() {
if (!editId.value || !store.currentSite) return;
window.open(`${config.serviceHost}${portalUrlOf(store.currentSite)}a/${editId.value}`, "_blank");
}
onMounted(load);
// 站点上下文切换即时重载(docs/09 §3.3):编辑页随当前站点切换重新拉取栏目与文章
watch(() => store.siteId, () => load());
</script>
<template>
<div class="page-wrapper">
<div class="main-container">
<Form ref="formRef" :model="form" :rules="rules" :label-width="100">
<FormItem label="所属栏目" prop="categoryId">
<Select v-model="form.categoryId" placeholder="请选择栏目" style="width: 320px">
<Option v-for="opt in categoryOptions" :key="opt.id" :value="opt.id">
{{ opt.label }}
</Option>
</Select>
</FormItem>
<FormItem label="标题" prop="title">
<Input v-model="form.title" placeholder="文章标题" style="width: 640px" :maxlength="200" />
</FormItem>
<FormItem label="封面图">
<Upload
:action="uploadUrl() + '?siteId=' + store.siteId"
with-credentials
:show-upload-list="false"
:format="['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp']"
:on-success="coverUploaded"
:on-error="coverError"
>
<Button icon="ios-cloud-upload-outline">上传封面</Button>
</Upload>
<img v-if="form.coverImage" :src="form.coverImage" class="cover-preview" />
</FormItem>
<FormItem label="摘要">
<Input
v-model="form.summary"
type="textarea"
:rows="2"
placeholder="列表/SEO 用,留空自动截取正文"
style="width: 640px"
:maxlength="500"
/>
</FormItem>
<FormItem label="作者">
<Input v-model="form.authorName" placeholder="默认取发布人" style="width: 240px" :maxlength="64" />
</FormItem>
<FormItem label="来源">
<Input v-model="form.source" placeholder="转载出处(可空)" style="width: 240px" :maxlength="128" />
</FormItem>
<FormItem label="发布时间">
<DatePicker
type="datetime"
v-model="form.publishTime"
format="yyyy-MM-dd HH:mm:ss"
placeholder="留空=发布时取当前时间"
style="width: 240px"
/>
<span class="form-tip">选择未来时间即“定时发布”,到期由系统自动发布</span>
</FormItem>
<FormItem label="下架时间">
<DatePicker
type="datetime"
v-model="form.offlineTime"
format="yyyy-MM-dd HH:mm:ss"
placeholder="可空"
style="width: 240px"
/>
</FormItem>
<FormItem label="置顶">
<Switch :model-value="form.topmost === 1" @on-change="(v: boolean) => (form.topmost = v ? 1 : 0)" />
</FormItem>
<FormItem label="正文">
<div class="editor-wrap">
<Toolbar class="editor-toolbar" :editor="editorRef" :defaultConfig="toolbarConfig" mode="default" />
<Editor
class="editor-content"
v-model="form.content"
:defaultConfig="editorConfig"
mode="default"
@onCreated="handleCreated"
/>
</div>
</FormItem>
<!-- 微信对接预留入口(方案 §7.4):本期禁用灰显,不产生任何请求 -->
<FormItem label="同步公众号">
<Tooltip max-width="280" content="微信对接未启用,规划见《微信平台对接方案》">
<Switch v-model="form.syncWechat" disabled />
</Tooltip>
</FormItem>
<FormItem>
<Button type="primary" :loading="saving" @click="saveAndPublish">保存并发布</Button>
<Button style="margin-left: 8px" :loading="saving" @click="saveDraft">保存草稿</Button>
<Button style="margin-left: 8px" :disabled="!editId" @click="preview">预览</Button>
<Button style="margin-left: 8px" @click="router.back()">返回</Button>
</FormItem>
</Form>
</div>
</div>
</template>
<style scoped lang="scss">
.cover-preview {
display: block;
max-width: 320px;
max-height: 160px;
margin-top: 8px;
border: 1px solid #e5e6eb;
}
.form-tip {
margin-left: 12px;
color: #999;
font-size: 12px;
}
.editor-wrap {
border: 1px solid #dcdee2;
border-radius: 4px;
z-index: 100;
.editor-toolbar {
border-bottom: 1px solid #dcdee2;
}
.editor-content {
// wangEditor v5 高度必须打在内层 .w-e-text-container(外层高度会被内部 100% 链路吞掉),
// Notice NewNotice.vue 同款修复(D6-5);视口自适应、下限 480px,430≈壳层(56+56)+表单区高度
:deep(.w-e-text-container) {
height: max(480px, calc(100vh - 430px)) !important;
}
overflow-y: hidden;
}
}
</style>