feat(admin): 用户协议版本新建支持继承上一版内容

新建草稿默认继承同语言上一版,已发布/归档行支持复制为草稿,并补充多语种文案。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
DaxPay Dev
2026-07-16 14:49:14 +08:00
parent cf3fa26446
commit 79f3986f2f
13 changed files with 233 additions and 23 deletions

View File

@@ -18,6 +18,13 @@ export const UserProtocolVersionApi = {
add(data: UserProtocolVersion): Promise<Result<void>> {
return defHttp.post({ url: '/user/protocol/version/add', data });
},
/** 查询新建草稿可继承的源版本(已发布优先, 否则最新归档) */
findInheritSource(protocolId: string, language: string): Promise<Result<UserProtocolVersion | null>> {
return defHttp.get({
url: '/user/protocol/version/find-inherit-source',
params: { protocolId, language },
});
},
/** 编辑草稿内容(仅草稿可编辑) */
update(data: UserProtocolVersion): Promise<Result<void>> {
return defHttp.post({ url: '/user/protocol/version/update', data });

View File

@@ -48,6 +48,9 @@
"confirmArchive": "Archive this version?",
"confirmDelete": "Are you sure to delete this draft?",
"confirmClose": "The content has been changed. Close anyway? Unsaved changes will be lost.",
"createFromThis": "Copy as draft",
"confirmReplaceByLanguage": "Switching language will replace the current content. Continue?",
"inheritedTip": "Content loaded from the previous version. Edit and save as a new draft.",
"statusDraft": "Draft",
"statusPublished": "Published",
"statusArchived": "Archived"

View File

@@ -48,6 +48,9 @@
"confirmArchive": "Arsipkan versi ini?",
"confirmDelete": "Apakah Anda yakin akan menghapus draf ini?",
"confirmClose": "Kontennya telah diubah. Tutup saja? Perubahan yang belum disimpan akan hilang.",
"createFromThis": "Salin sebagai draf",
"confirmReplaceByLanguage": "Mengganti bahasa akan menimpa konten saat ini. Lanjutkan?",
"inheritedTip": "Konten dimuat dari versi sebelumnya. Edit lalu simpan sebagai draf baru.",
"statusDraft": "Draf",
"statusPublished": "Diterbitkan",
"statusArchived": "Diarsipkan"

View File

@@ -48,6 +48,9 @@
"confirmArchive": "このバージョンをアーカイブしますか?",
"confirmDelete": "この下書きを削除してもよろしいですか?",
"confirmClose": "内容が変更になりました。とにかく閉じますか?保存されていない変更は失われます。",
"createFromThis": "下書きにコピー",
"confirmReplaceByLanguage": "言語を切り替えると現在の本文が上書きされます。続けますか?",
"inheritedTip": "前のバージョンから内容を読み込みました。編集して新しい下書きとして保存できます",
"statusDraft": "下書き",
"statusPublished": "発行済み",
"statusArchived": "アーカイブ済み"

View File

@@ -48,6 +48,9 @@
"confirmArchive": "이 버전을 보관처리하시겠습니까?",
"confirmDelete": "이 초안을 삭제하시겠습니까?",
"confirmClose": "내용이 변경되었습니다. 그래도 닫으시겠어요? 저장되지 않은 변경사항은 손실됩니다.",
"createFromThis": "초안으로 복사",
"confirmReplaceByLanguage": "언어를 바꾸면 현재 본문이 덮어씌워집니다. 계속할까요?",
"inheritedTip": "이전 버전에서 내용을 불러왔습니다. 수정 후 새 초안으로 저장하세요",
"statusDraft": "초안",
"statusPublished": "게시됨",
"statusArchived": "보관됨"

View File

@@ -48,6 +48,9 @@
"confirmArchive": "Arkibkan versi ini?",
"confirmDelete": "Adakah anda pasti akan memadamkan draf ini?",
"confirmClose": "Kandungan telah diubah. Tutup pula? Perubahan yang tidak disimpan akan hilang.",
"createFromThis": "Salin sebagai draf",
"confirmReplaceByLanguage": "Menukar bahasa akan menggantikan kandungan semasa. Teruskan?",
"inheritedTip": "Kandungan dimuatkan dari versi sebelumnya. Edit lalu simpan sebagai draf baharu.",
"statusDraft": "Draf",
"statusPublished": "Diterbitkan",
"statusArchived": "Diarkibkan"

View File

@@ -48,6 +48,9 @@
"confirmArchive": "เก็บเวอร์ชันนี้ไหม",
"confirmDelete": "คุณแน่ใจหรือว่าจะลบฉบับร่างนี้",
"confirmClose": "เนื้อหามีการเปลี่ยนแปลง ปิดรึยังคะ? การเปลี่ยนแปลงที่ไม่ได้บันทึกจะสูญหาย",
"createFromThis": "คัดลอกเป็นร่าง",
"confirmReplaceByLanguage": "เปลี่ยนภาษาจะเขียนทับเนื้อหาปัจจุบัน ดำเนินการต่อหรือไม่?",
"inheritedTip": "โหลดเนื้อหาจากรุ่นก่อนแล้ว แก้ไขแล้วบันทึกเป็นร่างใหม่ได้",
"statusDraft": "ร่าง",
"statusPublished": "ที่ตีพิมพ์",
"statusArchived": "เก็บถาวรแล้ว"

View File

@@ -48,6 +48,9 @@
"confirmArchive": "Lưu trữ phiên bản này?",
"confirmDelete": "Bạn có chắc chắn xóa bản nháp này không?",
"confirmClose": "Nội dung đã được thay đổi. Vẫn đóng? Những thay đổi chưa được lưu sẽ bị mất.",
"createFromThis": "Sao chép thành bản nháp",
"confirmReplaceByLanguage": "Đổi ngôn ngữ sẽ ghi đè nội dung hiện tại. Tiếp tục?",
"inheritedTip": "Đã tải nội dung từ phiên bản trước. Chỉnh sửa rồi lưu thành bản nháp mới.",
"statusDraft": "Bản nháp",
"statusPublished": "Đã xuất bản",
"statusArchived": "Đã lưu trữ"

View File

@@ -48,6 +48,9 @@
"confirmArchive": "确认归档该版本吗?",
"confirmDelete": "确认删除该草稿吗?",
"confirmClose": "协议内容已有改动,确认关闭吗?关闭后未保存的内容将丢失。",
"createFromThis": "复制为草稿",
"confirmReplaceByLanguage": "切换语言将覆盖当前正文,是否继续?",
"inheritedTip": "已从上一版本载入内容,可直接修改后保存为新草稿",
"statusDraft": "草稿",
"statusPublished": "已发布",
"statusArchived": "已归档"

View File

@@ -48,6 +48,9 @@
"confirmArchive": "確認歸檔該版本嗎?",
"confirmDelete": "確認刪除該草稿嗎?",
"confirmClose": "協議內容已有改動,確認關閉嗎?關閉後未保存的內容將丟失。",
"createFromThis": "複製為草稿",
"confirmReplaceByLanguage": "切換語言將覆蓋當前正文,是否繼續?",
"inheritedTip": "已從上一版本載入內容,可直接修改後保存為新草稿",
"statusDraft": "草稿",
"statusPublished": "已發佈",
"statusArchived": "已歸檔"

View File

@@ -48,6 +48,9 @@
"confirmArchive": "確認歸檔該版本嗎?",
"confirmDelete": "確認刪除該草稿嗎?",
"confirmClose": "協議內容已有改動,確認關閉嗎?關閉後未儲存的內容將丟失。",
"createFromThis": "複製為草稿",
"confirmReplaceByLanguage": "切換語言將覆蓋當前正文,是否繼續?",
"inheritedTip": "已從上一版本載入內容,可直接修改後儲存為新草稿",
"statusDraft": "草稿",
"statusPublished": "已釋出",
"statusArchived": "已歸檔"

View File

@@ -30,6 +30,12 @@
});
// 协议正文初始内容(用于关闭时检测是否有未保存修改)
const loadedContent = ref('');
// 最近一次继承载入的正文(用于语言切换时判断是否可自动覆盖)
const inheritedContent = ref('');
// 是否已从上一版本载入内容(展示提示)
const inherited = ref(false);
// 语言切换时用于回滚的上一次语言
const previousLanguage = ref('zh-CN');
// 语言选项(与 SUPPORT_LANGUAGES 对齐)
const languageOptions = [
@@ -53,27 +59,132 @@
content: [{ required: true, message: $t('system.protocol.version.inputContent') }],
};
/** 入口 */
function init(protocolId: string, id: string | undefined, editType: FormEditType) {
/**
* 入口
* @param protocolId 协议ID
* @param id 版本ID(编辑/查看时)
* @param editType 表单模式
* @param sourceVersionId 基于指定版本新建时传入源版本ID
*/
function init(protocolId: string, id: string | undefined, editType: FormEditType, sourceVersionId?: string) {
initFormEditType(editType);
resetForm();
form.value.protocolId = protocolId;
getInfo(id, editType);
previousLanguage.value = form.value.language ?? 'zh-CN';
getInfo(id, editType, sourceVersionId);
}
/** 将源版本字段预填到新建草稿表单(不带 id/状态/版本号, summary 清空) */
function applyInheritSource(source: UserProtocolVersion, protocolId: string) {
const content = source.content ?? '';
form.value = {
protocolId,
language: source.language || 'zh-CN',
contentFormat: source.contentFormat || 'MARKDOWN',
versionLabel: source.versionLabel,
title: source.title,
content,
contentHtml: source.contentHtml ?? '',
// 变更说明需重写
summary: '',
};
inheritedContent.value = content;
loadedContent.value = content;
inherited.value = true;
previousLanguage.value = form.value.language ?? 'zh-CN';
}
/** 按协议+语言拉取可继承源版本并预填; 无源则保持空白 */
async function loadInheritByLanguage(protocolId: string, language: string) {
const { data } = await UserProtocolVersionApi.findInheritSource(protocolId, language);
if (data) {
applyInheritSource(data, protocolId);
// 保持用户刚切换的目标语言(源可能同语言)
form.value.language = language;
previousLanguage.value = language;
} else {
form.value.language = language;
form.value.title = undefined;
form.value.versionLabel = undefined;
form.value.content = '';
form.value.contentHtml = '';
form.value.summary = '';
form.value.contentFormat = 'MARKDOWN';
inheritedContent.value = '';
loadedContent.value = '';
inherited.value = false;
previousLanguage.value = language;
}
}
/** 获取信息 */
function getInfo(id: string | undefined, editType: FormEditType) {
function getInfo(id: string | undefined, editType: FormEditType, sourceVersionId?: string) {
if ([FormEditType.Edit, FormEditType.Show].includes(editType) && id) {
confirmLoading.value = true;
UserProtocolVersionApi.findById(id).then(({ data }) => {
form.value = data;
// 记录正文初始值, 用于关闭时检测变动
loadedContent.value = data.content ?? '';
UserProtocolVersionApi.findById(id)
.then(({ data }) => {
form.value = data;
// 记录正文初始值, 用于关闭时检测变动
loadedContent.value = data.content ?? '';
inheritedContent.value = '';
inherited.value = false;
previousLanguage.value = data.language ?? 'zh-CN';
})
.finally(() => {
confirmLoading.value = false;
});
return;
}
// 新建: 基于指定版本 或 默认继承同语言上一版
if (editType === FormEditType.Add) {
const protocolId = form.value.protocolId!;
confirmLoading.value = true;
const loadPromise = sourceVersionId
? UserProtocolVersionApi.findById(sourceVersionId).then(({ data }) => {
applyInheritSource(data, protocolId);
})
: loadInheritByLanguage(protocolId, form.value.language || 'zh-CN');
loadPromise.finally(() => {
confirmLoading.value = false;
});
} else {
confirmLoading.value = false;
return;
}
confirmLoading.value = false;
}
/** 新建模式下切换语言: 未改过继承正文则自动覆盖, 否则二次确认 */
function handleLanguageChange(language: string) {
if (formEditType.value !== FormEditType.Add || !form.value.protocolId) {
previousLanguage.value = language;
return;
}
const content = form.value.content ?? '';
const canAutoReplace = !content || content === inheritedContent.value;
if (canAutoReplace) {
confirmLoading.value = true;
loadInheritByLanguage(form.value.protocolId, language).finally(() => {
confirmLoading.value = false;
});
return;
}
// 正文已手动改过, 确认后再覆盖
confirm({
title: $t('common.warning'),
// 切换语言将覆盖当前正文
content: $t('system.protocol.version.confirmReplaceByLanguage'),
onOk: () => {
confirmLoading.value = true;
return loadInheritByLanguage(form.value.protocolId!, language).finally(() => {
confirmLoading.value = false;
});
},
onCancel: () => {
// 取消时回滚语言选择
form.value.language = previousLanguage.value;
},
});
}
/** 重置表单 */
@@ -88,6 +199,9 @@
contentHtml: '',
};
loadedContent.value = '';
inheritedContent.value = '';
inherited.value = false;
previousLanguage.value = 'zh-CN';
}
/** Markdown 渲染后的HTML */
@@ -148,6 +262,10 @@
</a-space>
</template>
<a-spin :spinning="confirmLoading">
<!-- 已从上一版本载入提示 -->
<div v-if="inherited && !showable" class="mb-4">
<a-alert type="info" show-icon :message="$t('system.protocol.version.inheritedTip')" />
</div>
<a-form ref="formRef" :model="form" :rules="rules" :label-col="{ span: 4 }" :wrapper-col="{ span: 18 }">
<!-- 主键 -->
<a-form-item label="ID" name="id" :hidden="true">
@@ -160,6 +278,7 @@
:disabled="showable"
:options="languageOptions"
:placeholder="$t('common.pleaseSelect')"
@change="handleLanguageChange"
/>
</a-form-item>
<!-- 版本标签 -->

View File

@@ -1,8 +1,10 @@
<script lang="ts" setup>
import type { MenuProps } from 'antdv-next';
import type { VxeTableInstance, VxeToolbarInstance } from 'vxe-table';
import { ref } from 'vue';
import { IconifyIcon } from '@vben-core/icons';
import { $t } from '@vben/locales';
import { UserProtocolVersionApi } from '#/api/system/protocol/user-protocol-version.api';
@@ -82,11 +84,16 @@
queryPage();
}
/** 新建草稿 */
/** 新建草稿(默认继承同语言上一版) */
function handleAdd() {
versionEdit.value.init(protocolId.value, undefined, FormEditType.Add);
}
/** 基于指定已发布/归档版本新建草稿 */
function handleCreateFrom(row: any) {
versionEdit.value.init(protocolId.value, undefined, FormEditType.Add, row.id);
}
/** 编辑草稿 */
function handleEdit(row: any) {
versionEdit.value.init(protocolId.value, row.id, FormEditType.Edit);
@@ -145,6 +152,45 @@
});
}
/**
* 更多操作菜单
* 草稿: 删除; 已发布: 归档
*/
function getActionMenu(row: any): MenuProps {
const items: NonNullable<MenuProps['items']> = [];
if (row.status === 'DRAFT') {
items.push({
key: 'delete',
// 删除草稿
label: $t('common.delete'),
danger: true,
});
}
if (row.status === 'PUBLISHED') {
items.push({
key: 'archive',
// 归档
label: $t('system.protocol.version.archive'),
});
}
return {
items,
onClick: ({ key }: { key: string }) => {
if (key === 'delete') {
handleDelete(row);
}
if (key === 'archive') {
handleArchive(row);
}
},
};
}
/** 是否展示更多菜单 */
function hasMoreActions(row: any) {
return row.status === 'DRAFT' || row.status === 'PUBLISHED';
}
/** 分页变化 */
function handlePageChange({ currentPage, pageSize }: any) {
pageConfig.value.currentPage = currentPage;
@@ -246,7 +292,7 @@
<!-- 变更说明 -->
<vxe-column field="summary" :title="$t('system.protocol.version.summary')" :min-width="160" />
<!-- 操作 -->
<vxe-column fixed="right" width="240" :show-overflow="false" :title="$t('common.operation')">
<vxe-column fixed="right" :width="220" :show-overflow="false" :title="$t('common.operation')">
<template #default="{ row }">
<a-space :size="2">
<template #separator>
@@ -256,22 +302,30 @@
<a-button v-if="row.status === 'DRAFT'" type="link" size="small" @click="handleEdit(row)">{{
$t('common.edit')
}}</a-button>
<!-- 查看(非草稿) -->
<a-button v-if="row.status !== 'DRAFT'" type="link" size="small" @click="handleView(row)">{{
$t('common.view')
}}</a-button>
<!-- 发布(仅草稿) -->
<a-button v-if="row.status === 'DRAFT'" type="link" size="small" @click="handlePublish(row)">{{
$t('system.protocol.version.publish')
}}</a-button>
<!-- 归档(仅已发布) -->
<a-button v-if="row.status === 'PUBLISHED'" type="link" size="small" @click="handleArchive(row)">{{
$t('system.protocol.version.archive')
}}</a-button>
<!-- 删除(仅草稿) -->
<a-button v-if="row.status === 'DRAFT'" type="link" size="small" danger @click="handleDelete(row)">{{
$t('common.delete')
<!-- 查看(非草稿) -->
<a-button v-if="row.status !== 'DRAFT'" type="link" size="small" @click="handleView(row)">{{
$t('common.view')
}}</a-button>
<!-- 基于此版本新建(已发布/归档) -->
<a-button
v-if="row.status === 'PUBLISHED' || row.status === 'ARCHIVED'"
type="link"
size="small"
@click="handleCreateFrom(row)"
>
{{ $t('system.protocol.version.createFromThis') }}
</a-button>
<!-- 更多: 草稿删除 / 已发布归档 -->
<a-dropdown v-if="hasMoreActions(row)" :menu="getActionMenu(row)">
<a-button type="link" size="small">
{{ $t('common.more') }}
<IconifyIcon icon="ant-design:down-outlined" class="inline" />
</a-button>
</a-dropdown>
</a-space>
</template>
</vxe-column>