init local code

This commit is contained in:
chenxing
2026-09-07 09:11:37 +08:00
commit a3a75c3680
112 changed files with 24837 additions and 0 deletions
+333
View File
@@ -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>