init local code
This commit is contained in:
@@ -0,0 +1,333 @@
|
||||
<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, portalBase, uploadUrl } from "@/api/module/cms";
|
||||
import { useCmsStore } from "@/store";
|
||||
import config from "@/assets/config/host.config";
|
||||
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) 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(`${portalBase(store.currentSite.code)}/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>
|
||||
Reference in New Issue
Block a user