342 lines
13 KiB
Vue
342 lines
13 KiB
Vue
<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>
|