article
This commit is contained in:
1 parent
c6eb00b406
commit
1c91f62ff6
58 files changed
+3888
-69
No files matched your search
@@ -0,0 +1,621 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 有家文化传承「文章」底部编辑弹层(新建 / 编辑复用,图3 的修改入口)。
|
||||
* 受控组件:只渲染 + 交互,数据整包托管给父级传入的 useArticles 实例(props.api.detail),
|
||||
* 标题/正文/封面/群组/配图全部本地改整包,保存读富文本 html 回写 Brief 后 api.save() 落库。
|
||||
* 契约(对齐相册编辑弹层 + 后端 UploadFolderPolicy):
|
||||
* 1. 封面走 /DmsFile/UploadFile,FilePath 头 = "ArticleCover";DB 存分片物理名 ArticleCoverFilename;
|
||||
* 2. 正文配图同样走 /DmsFile/UploadFile,FilePath 头 = "ArticleImage",物理名进 article_filename 明细;
|
||||
* 3. 富文本用 uni-app 内置 <editor>(H5 + App webview):ready 取 editorCtx,打开时 setContents 灌 Brief,
|
||||
* 保存前 getContents 取回 html;插入图片后同步登记配图明细;
|
||||
* 4. 群组候选复用 useFamilyGroups(我可见的群),增删只改本地整包,保存由后端按 FamilyName 差量落库。
|
||||
*/
|
||||
import type { useArticles } from "@/composables/useArticles";
|
||||
import { useLocale } from "uview-pro";
|
||||
import { computed, getCurrentInstance, ref, watch } from "vue";
|
||||
import { buildChunkFileName, uploadFile } from "@/common/http.interceptor";
|
||||
import { useFamilyGroups } from "@/composables";
|
||||
import { briefForDisplay, briefForStore } from "@/composables/useArticles";
|
||||
import { absoluteFileUrl, backupFileUrl } from "@/utils/fileUrl";
|
||||
|
||||
type ArticleApi = ReturnType<typeof useArticles>;
|
||||
|
||||
const props = defineProps<{
|
||||
visible: boolean;
|
||||
api: ArticleApi;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "close"): void;
|
||||
(e: "saved"): void;
|
||||
}>();
|
||||
|
||||
const { t } = useLocale();
|
||||
|
||||
/** 与后端 UploadFolderPolicy 同名的落盘目录(上传 FilePath 头 / 保存前预览打包地址共用) */
|
||||
const COVER_FOLDER = "ArticleCover";
|
||||
const IMAGE_FOLDER = "ArticleImage";
|
||||
|
||||
const familyGroups = useFamilyGroups();
|
||||
|
||||
// 整包托管:直接引用 api 的 computed/ref,改动即反映到 api.detail
|
||||
const article = props.api.article;
|
||||
const groups = props.api.groups;
|
||||
const filenames = props.api.filenames;
|
||||
|
||||
const saving = ref(false);
|
||||
const coverUploading = ref(false);
|
||||
/** 封面预览:新上传用本地临时地址兜底,编辑态用后端打包的 CoverUrl */
|
||||
const coverPreview = ref("");
|
||||
|
||||
// ===== 富文本编辑器 =====
|
||||
let editorCtx: any = null;
|
||||
const editorReady = ref(false);
|
||||
/** 弹层在 editor ready 前就要求灌内容时暂存,ready 后补写 */
|
||||
let pendingHtml: string | null = null;
|
||||
|
||||
const editorInstance = getCurrentInstance();
|
||||
|
||||
/** 当前应灌进编辑器的正文(限宽样式烘入 + img src 归一到当前端可达绝对地址) */
|
||||
function currentHtml(): string {
|
||||
return briefForDisplay(article.value?.Brief);
|
||||
}
|
||||
|
||||
/**
|
||||
* 拿到 editor 上下文后统一收尾:置就绪 + 灌入正文。
|
||||
* u-popup 内容是 v-if,弹层每次重开都会重建 <editor>:旧 ctx 已失效,
|
||||
* 新实例 ready 时只要弹层开着就重取最新 Brief 补灌(不依赖 pendingHtml 是否被消费)。
|
||||
*/
|
||||
function bindEditorContext(ctx: any): void {
|
||||
if (!ctx) {
|
||||
return;
|
||||
}
|
||||
editorCtx = ctx;
|
||||
editorReady.value = true;
|
||||
if (props.visible && pendingHtml === null) {
|
||||
pendingHtml = currentHtml();
|
||||
}
|
||||
if (pendingHtml !== null) {
|
||||
editorCtx.setContents({ html: pendingHtml });
|
||||
pendingHtml = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 取 uni-app <editor> 上下文的跨端正确写法:createSelectorQuery().select().context()。
|
||||
* H5/App 端 ready 事件的 e.detail.editor 可能为 undefined(曾导致正文读写全废),
|
||||
* 故以 selector query 为准,e.detail.editor 仅作兜底;小程序端需 .in(组件实例) 限定作用域。
|
||||
*/
|
||||
function onEditorReady(e?: any): void {
|
||||
const hasCtx = (c: any): boolean => !!c && typeof c.setContents === "function";
|
||||
if (hasCtx(e?.detail?.editor)) {
|
||||
bindEditorContext(e.detail.editor);
|
||||
return;
|
||||
}
|
||||
const query = editorInstance
|
||||
? uni.createSelectorQuery().in(editorInstance.proxy)
|
||||
: uni.createSelectorQuery();
|
||||
query.select("#article-editor")
|
||||
.context((res: any) => bindEditorContext(res?.context))
|
||||
.exec();
|
||||
}
|
||||
|
||||
function format(key: string): void {
|
||||
editorCtx?.format(key, {});
|
||||
}
|
||||
|
||||
/** 取回当前富文本 html(getContents 异步,包一层 Promise 供保存串行使用)。
|
||||
* 编辑器未就绪或读取失败时兑当前 Brief,绝不可返回空串——否则一次失败就把正文洗掉 */
|
||||
function readHtml(): Promise<string> {
|
||||
if (!editorReady.value || !editorCtx) {
|
||||
return Promise.resolve(String(article.value?.Brief ?? ""));
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
editorCtx.getContents({
|
||||
success: (res: any) => resolve(res.html || String(article.value?.Brief ?? "")),
|
||||
fail: () => resolve(String(article.value?.Brief ?? "")),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** 打开时把整包 Brief 灌进编辑器;关闭不重置,便于连续编辑 */
|
||||
watch(
|
||||
() => props.visible,
|
||||
(open) => {
|
||||
if (!open) {
|
||||
return;
|
||||
}
|
||||
// 灌入前给插图烘上限宽样式:编辑器内也按屏宽显示,且样式随保存回写进 Brief
|
||||
pendingHtml = currentHtml();
|
||||
// 封面预览:后端已保存态用打包的 CoverUrl;未保存的新上传按落盘口径拼直链(上传即落盘,保存前就能取到图)
|
||||
coverPreview.value = article.value?.CoverUrl
|
||||
? absoluteFileUrl(article.value.CoverUrl)
|
||||
: backupFileUrl(COVER_FOLDER, article.value?.ArticleCoverFilename);
|
||||
// 旧 ctx 可能指向上次弹层里已销毁的 editor:打不进去就留给新实例 ready 时补灌
|
||||
if (editorReady.value && editorCtx) {
|
||||
try {
|
||||
editorCtx.setContents({ html: pendingHtml });
|
||||
pendingHtml = null;
|
||||
}
|
||||
catch {
|
||||
/* 弹层重建了 editor,等 @ready 重新取上下文后补写 */
|
||||
}
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
// ===== 封面上传 =====
|
||||
function onPickCover(): void {
|
||||
if (coverUploading.value) {
|
||||
return;
|
||||
}
|
||||
uni.chooseImage({
|
||||
count: 1,
|
||||
success: async (res: any) => {
|
||||
const filePath = String(res?.tempFilePaths?.[0] ?? "");
|
||||
const fileName = String(res?.tempFiles?.[0]?.name || `cover-${Date.now()}.png`);
|
||||
if (!filePath || !article.value) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
coverUploading.value = true;
|
||||
const physicalName = buildChunkFileName(fileName);
|
||||
await uploadFile({
|
||||
url: "/DmsFile/UploadFile",
|
||||
filePath,
|
||||
name: fileName,
|
||||
fileName: physicalName,
|
||||
header: { FilePath: COVER_FOLDER },
|
||||
});
|
||||
article.value.ArticleCoverFilename = physicalName;
|
||||
coverPreview.value = filePath;
|
||||
}
|
||||
catch (error: any) {
|
||||
uni.showToast({ title: error?.message || t("article.uploadFailed"), icon: "none" });
|
||||
}
|
||||
finally {
|
||||
coverUploading.value = false;
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ===== 正文配图:上传 + 编辑器内联 + 登记明细 =====
|
||||
function onInsertImage(): void {
|
||||
uni.chooseImage({
|
||||
count: 1,
|
||||
success: async (res: any) => {
|
||||
const filePath = String(res?.tempFilePaths?.[0] ?? "");
|
||||
const fileName = String(res?.tempFiles?.[0]?.name || `img-${Date.now()}.png`);
|
||||
if (!filePath) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const physicalName = buildChunkFileName(fileName);
|
||||
await uploadFile({
|
||||
url: "/DmsFile/UploadFile",
|
||||
filePath,
|
||||
name: fileName,
|
||||
fileName: physicalName,
|
||||
header: { FilePath: IMAGE_FOLDER },
|
||||
});
|
||||
props.api.addImage(physicalName);
|
||||
// 内联 src 用落盘直链(非裸物理名):保存前可见,且这个地址会烘进 Brief,保存后仍有效
|
||||
editorCtx?.insertImage({ src: backupFileUrl(IMAGE_FOLDER, physicalName), alt: fileName }, () => { });
|
||||
}
|
||||
catch (error: any) {
|
||||
uni.showToast({ title: error?.message || t("article.uploadFailed"), icon: "none" });
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function onRemoveImage(physicalName: string): void {
|
||||
props.api.removeImage(physicalName);
|
||||
}
|
||||
|
||||
/** 复用已上传配图:点缩略图把图重新插回正文(不重复上传),插入位置跟随编辑器最后光标 */
|
||||
function onInsertExisting(img: { ImageUrl: string; FileName: string }): void {
|
||||
const src = absoluteFileUrl(img.ImageUrl) || backupFileUrl(IMAGE_FOLDER, img.FileName);
|
||||
if (!src) {
|
||||
return;
|
||||
}
|
||||
if (!editorReady.value || !editorCtx) {
|
||||
uni.showToast({ title: t("article.editorNotReady"), icon: "none" });
|
||||
return;
|
||||
}
|
||||
editorCtx.insertImage({ src, alt: img.FileName }, () => {
|
||||
uni.showToast({ title: t("article.imageInserted"), icon: "none" });
|
||||
});
|
||||
}
|
||||
|
||||
// ===== 群组选择器(复用共享数据源,ActionSheet 选一个未关联的群) =====
|
||||
async function onAddGroup(): Promise<void> {
|
||||
await familyGroups.loadList();
|
||||
const candidates = familyGroups.groups.value.filter(
|
||||
g => !groups.value.some(row => row.FamilyName === g.FamilyName),
|
||||
);
|
||||
if (!candidates.length) {
|
||||
uni.showToast({ title: t("article.noGroupCandidate"), icon: "none" });
|
||||
return;
|
||||
}
|
||||
uni.showActionSheet({
|
||||
itemList: candidates.map(g => g.FamilyLocalName || g.FamilyName),
|
||||
success: (res) => {
|
||||
const pick = candidates[res.tapIndex];
|
||||
props.api.addGroup(pick.FamilyName, pick.FamilyLocalName || "");
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function onRemoveGroup(familyName: string): void {
|
||||
props.api.removeGroup(familyName);
|
||||
}
|
||||
|
||||
// ===== 保存 / 取消 =====
|
||||
async function onSave(): Promise<void> {
|
||||
if (!article.value) {
|
||||
return;
|
||||
}
|
||||
if (!(article.value.ArticleTitle || "").trim()) {
|
||||
uni.showToast({ title: t("article.titleRequired"), icon: "none" });
|
||||
return;
|
||||
}
|
||||
saving.value = true;
|
||||
try {
|
||||
// 保存前烘限宽样式并把 img src 剥成 host 无关相对路径:新插入/复用的图也带约束、且落库跨环境可移植
|
||||
article.value.Brief = briefForStore(await readHtml());
|
||||
await props.api.save();
|
||||
emit("saved");
|
||||
}
|
||||
catch (error: any) {
|
||||
uni.showToast({ title: error?.message || t("FilePage.actionFailed"), icon: "none" });
|
||||
}
|
||||
finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
const imageCount = computed(() => filenames.value.length);
|
||||
|
||||
/** 标题双算子:读写透传到整包 article.ArticleTitle,避开模板对可空 computed 的非空断言 */
|
||||
const title = computed({
|
||||
get: () => article.value?.ArticleTitle ?? "",
|
||||
set: (val: string) => {
|
||||
if (article.value) {
|
||||
article.value.ArticleTitle = val;
|
||||
}
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<u-popup :model-value="visible" mode="bottom" round="24" @close="emit('close')">
|
||||
<view class="article-editor">
|
||||
<view class="article-editor__head">
|
||||
<text class="article-editor__title">{{ t("article.editTitle") }}</text>
|
||||
<u-icon name="close" size="32" color="#8f959e" @click="emit('close')" />
|
||||
</view>
|
||||
|
||||
<scroll-view scroll-y class="article-editor__scroll">
|
||||
<view class="article-editor__form">
|
||||
<!-- 标题 -->
|
||||
<view class="ae-field">
|
||||
<text class="ae-field__label">{{ t("article.fieldTitle") }}</text>
|
||||
<input v-model="title" class="ae-field__input" :maxlength="100"
|
||||
:placeholder="t('article.titleRequired')">
|
||||
</view>
|
||||
|
||||
<!-- 封面 -->
|
||||
<view class="ae-field">
|
||||
<text class="ae-field__label">{{ t("article.fieldCover") }}</text>
|
||||
<view class="ae-cover" @tap="onPickCover">
|
||||
<image v-if="coverPreview" class="ae-cover__img" :src="coverPreview" mode="aspectFill" />
|
||||
<view v-else class="ae-cover__ph">
|
||||
<u-icon name="camera-fill" size="40" color="#c0c4cc" />
|
||||
<text class="ae-cover__ph-text">{{ t("article.coverPick") }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 正文富文本 -->
|
||||
<view class="ae-field">
|
||||
<text class="ae-field__label">{{ t("article.fieldContent") }}</text>
|
||||
<view class="ae-toolbar">
|
||||
<text class="ae-toolbar__btn" @tap="format('bold')"><text
|
||||
class="ae-toolbar__b">B</text></text>
|
||||
<text class="ae-toolbar__btn" @tap="format('italic')"><text
|
||||
class="ae-toolbar__i">I</text></text>
|
||||
<text class="ae-toolbar__btn" @tap="format('underline')"><text
|
||||
class="ae-toolbar__u">U</text></text>
|
||||
<text class="ae-toolbar__btn" @tap="format('header')">{{ t("article.fmtHeader") }}</text>
|
||||
<text class="ae-toolbar__btn" @tap="format('insertOrderedList')">{{ t("article.fmtList")
|
||||
}}</text>
|
||||
<text class="ae-toolbar__btn" @tap="onInsertImage">{{ t("article.fmtImage") }}</text>
|
||||
</view>
|
||||
<editor id="article-editor" class="ae-editor" :read-only="false" placeholder=""
|
||||
@ready="onEditorReady" />
|
||||
</view>
|
||||
|
||||
<!-- 配图明细 -->
|
||||
<view class="ae-field">
|
||||
<view class="ae-field__rowhead">
|
||||
<text class="ae-field__label">{{ t("article.fieldImages") }}({{ imageCount }})</text>
|
||||
<text class="ae-field__add" @tap="onInsertImage">+ {{ t("article.addImage") }}</text>
|
||||
</view>
|
||||
<view v-if="!imageCount" class="ae-empty"><text>{{ t("article.noImage") }}</text></view>
|
||||
<template v-else>
|
||||
<text class="ae-images__tip">{{ t("article.imageTapHint") }}</text>
|
||||
<scroll-view scroll-x class="ae-images">
|
||||
<view v-for="img in filenames" :key="img.FileName" class="ae-images__item">
|
||||
<image class="ae-images__img" mode="aspectFill"
|
||||
:src="absoluteFileUrl(img.ImageUrl) || backupFileUrl(IMAGE_FOLDER, img.FileName)"
|
||||
@tap="onInsertExisting(img)" />
|
||||
<view class="ae-images__del" @tap.stop="onRemoveImage(img.FileName)">
|
||||
<u-icon name="close" size="20" color="#ffffff" />
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</template>
|
||||
</view>
|
||||
|
||||
<!-- 关联群组 -->
|
||||
<view class="ae-field">
|
||||
<view class="ae-field__rowhead">
|
||||
<text class="ae-field__label">{{ t("article.fieldGroups") }}</text>
|
||||
<text class="ae-field__add" @tap="onAddGroup">+ {{ t("article.addGroup") }}</text>
|
||||
</view>
|
||||
<view v-if="!groups.length" class="ae-empty"><text>{{ t("article.noGroup") }}</text></view>
|
||||
<view v-for="g in groups" :key="g.FamilyName" class="ae-group">
|
||||
<text class="ae-group__name">{{ g.FamilyLocalName || g.FamilyName }}</text>
|
||||
<text class="ae-group__del" @tap="onRemoveGroup(g.FamilyName)">{{ t("article.delete")
|
||||
}}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
|
||||
<view class="article-editor__foot">
|
||||
<view class="article-editor__btn" @tap="emit('close')">
|
||||
<text>{{ t("article.cancel") }}</text>
|
||||
</view>
|
||||
<view class="article-editor__btn article-editor__btn--primary" :class="{ 'is-busy': saving }"
|
||||
@tap="onSave">
|
||||
<text>{{ saving ? t("FilePage.loading") : t("article.save") }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</u-popup>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.article-editor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: 86vh;
|
||||
padding: 32rpx 32rpx calc(32rpx + env(safe-area-inset-bottom));
|
||||
background-color: #ffffff;
|
||||
border-radius: 24rpx 24rpx 0 0;
|
||||
}
|
||||
|
||||
.article-editor__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 24rpx;
|
||||
}
|
||||
|
||||
.article-editor__title {
|
||||
font-size: 34rpx;
|
||||
font-weight: 700;
|
||||
color: #1f2329;
|
||||
}
|
||||
|
||||
.article-editor__scroll {
|
||||
/* 明确封顶高度:scroll-view 在 uni-app/H5 需确定高度才会内部滚动,
|
||||
否则内容(尤其加配图后)撑满把底部取消/保存顶出可视区(同 person-editor / option-picker) */
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
max-height: 60vh;
|
||||
}
|
||||
|
||||
.article-editor__form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 28rpx;
|
||||
}
|
||||
|
||||
.ae-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12rpx;
|
||||
}
|
||||
|
||||
.ae-field__label {
|
||||
font-size: 26rpx;
|
||||
color: #5c626b;
|
||||
}
|
||||
|
||||
.ae-field__rowhead {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.ae-field__add {
|
||||
font-size: 24rpx;
|
||||
color: var(--fg-primary, #2b8fff);
|
||||
}
|
||||
|
||||
.ae-field__input {
|
||||
height: 76rpx;
|
||||
padding: 0 20rpx;
|
||||
font-size: 28rpx;
|
||||
color: #1f2329;
|
||||
background-color: #f5f6f8;
|
||||
border-radius: 16rpx;
|
||||
}
|
||||
|
||||
.ae-cover {
|
||||
width: 220rpx;
|
||||
height: 150rpx;
|
||||
overflow: hidden;
|
||||
border-radius: 16rpx;
|
||||
background-color: #f5f6f8;
|
||||
}
|
||||
|
||||
.ae-cover__img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.ae-cover__ph {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
gap: 8rpx;
|
||||
}
|
||||
|
||||
.ae-cover__ph-text {
|
||||
font-size: 22rpx;
|
||||
color: #bfc3c9;
|
||||
}
|
||||
|
||||
.ae-toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12rpx;
|
||||
padding: 12rpx;
|
||||
background-color: #f5f6f8;
|
||||
border-radius: 16rpx 16rpx 0 0;
|
||||
}
|
||||
|
||||
.ae-toolbar__btn {
|
||||
min-width: 56rpx;
|
||||
padding: 6rpx 16rpx;
|
||||
font-size: 26rpx;
|
||||
color: #5c626b;
|
||||
text-align: center;
|
||||
background-color: #ffffff;
|
||||
border-radius: 10rpx;
|
||||
}
|
||||
|
||||
.ae-toolbar__b {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.ae-toolbar__i {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.ae-toolbar__u {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.ae-editor {
|
||||
min-height: 560rpx;
|
||||
padding: 20rpx;
|
||||
font-size: 28rpx;
|
||||
line-height: 44rpx;
|
||||
color: #1f2329;
|
||||
background-color: #fafbfc;
|
||||
border: 1rpx solid #eceef1;
|
||||
border-radius: 0 0 16rpx 16rpx;
|
||||
}
|
||||
|
||||
.ae-empty {
|
||||
padding: 28rpx;
|
||||
font-size: 24rpx;
|
||||
color: #9aa0a6;
|
||||
text-align: center;
|
||||
background-color: #f7f8fa;
|
||||
border-radius: 16rpx;
|
||||
}
|
||||
|
||||
.ae-images {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ae-images__tip {
|
||||
font-size: 22rpx;
|
||||
color: #9aa0a6;
|
||||
}
|
||||
|
||||
.ae-images__item {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 140rpx;
|
||||
height: 140rpx;
|
||||
margin-right: 16rpx;
|
||||
overflow: hidden;
|
||||
border-radius: 16rpx;
|
||||
}
|
||||
|
||||
.ae-images__img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.ae-images__del {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40rpx;
|
||||
height: 40rpx;
|
||||
background-color: rgba(0, 0, 0, 0.45);
|
||||
border-radius: 0 0 0 16rpx;
|
||||
}
|
||||
|
||||
.ae-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 20rpx 24rpx;
|
||||
background-color: #f7f8fa;
|
||||
border-radius: 16rpx;
|
||||
}
|
||||
|
||||
.ae-group__name {
|
||||
font-size: 28rpx;
|
||||
color: #1f2329;
|
||||
}
|
||||
|
||||
.ae-group__del {
|
||||
font-size: 24rpx;
|
||||
color: #f0413e;
|
||||
}
|
||||
|
||||
.article-editor__foot {
|
||||
display: flex;
|
||||
gap: 20rpx;
|
||||
margin-top: 28rpx;
|
||||
}
|
||||
|
||||
.article-editor__btn {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 88rpx;
|
||||
font-size: 30rpx;
|
||||
color: #5c626b;
|
||||
background-color: #f2f3f5;
|
||||
border-radius: 999rpx;
|
||||
}
|
||||
|
||||
.article-editor__btn--primary {
|
||||
color: #ffffff;
|
||||
background: var(--fg-gradient, #ff9d45);
|
||||
}
|
||||
|
||||
.article-editor__btn--primary.is-busy {
|
||||
opacity: 0.6;
|
||||
}
|
||||
</style>
|
||||
@@ -20,6 +20,7 @@ import { brandGlyph } from "@/common/brand";
|
||||
import { buildChunkFileName, uploadFile } from "@/common/http.interceptor";
|
||||
import { userTypeGlyph, userTypePillBg } from "@/common/userTypeIcons";
|
||||
import { useIdCardVerify, useThemeGradient, useThemeTokens } from "@/composables";
|
||||
import { useArticles } from "@/composables/useArticles";
|
||||
import { formatFileSize, formatFileTime, useFamilyTreeExchange } from "@/composables/useFamilyTreeExchange";
|
||||
import { usePersonPickConfig } from "@/composables/usePickerConfigs";
|
||||
import { usePolicyOptions } from "@/composables/usePolicyOptions";
|
||||
@@ -37,8 +38,9 @@ import familyPersonService from "@/service/master/familyPersonService";
|
||||
import familyTreeService from "@/service/master/familyTreeService";
|
||||
import dmsFileService from "@/service/master/dmsFileService";
|
||||
import { useLoginProfileStore } from "@/stores";
|
||||
import { pageUrl } from "@/router/routes";
|
||||
import { chooseFiles } from "@/utils/chooseFiles";
|
||||
import { absoluteFileUrl } from "@/utils/fileUrl";
|
||||
import { absoluteFileUrl, backupFileUrl } from "@/utils/fileUrl";
|
||||
|
||||
/** ready:组件挂载完成通知(时序口径见文件底部 onMounted 注释) */
|
||||
const emit = defineEmits<{ (e: "ready"): void }>();
|
||||
@@ -239,6 +241,60 @@ async function onRetry(): Promise<void> {
|
||||
await loadTrees();
|
||||
}
|
||||
|
||||
// ===== 有家文化传承(文章)卡片:与族谱列表并列,数据源见 composables/useArticles =====
|
||||
const articlesApi = useArticles();
|
||||
const articleKeyword = ref("");
|
||||
const showArticleSearch = ref(false);
|
||||
const articleCollapsed = ref(false);
|
||||
/** 首页「+」新建弹层可见(编辑层不另开页,直接底部弹出,同相册/群编辑) */
|
||||
const articleEditorVisible = ref(false);
|
||||
/** 首页卡只展示最新 5 条(列表已按置顶、创建时间排序) */
|
||||
const homeArticles = computed(() => articlesApi.list.value.slice(0, 5));
|
||||
|
||||
/** 无封面时的书卷字形:内联 SVG(同 treeGlyph 套路),白描卷轴 */
|
||||
const articleGlyph = `data:image/svg+xml,${encodeURIComponent([
|
||||
"<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 48 48' fill='none' stroke='#ffffff' stroke-width='2.6' stroke-linecap='round' stroke-linejoin='round'>",
|
||||
"<path d='M10 12c0-2 1.6-3.6 3.6-3.6H30v30H13.6C11.6 38.4 10 36.8 10 34.8Z'/>",
|
||||
"<path d='M30 8.4h6.4c2 0 3.6 1.6 3.6 3.6v22.8c0 2-1.6 3.6-3.6 3.6H30'/>",
|
||||
"<path d='M15 16h10M15 22h10'/>",
|
||||
"</svg>",
|
||||
].join(""))}`;
|
||||
|
||||
async function loadArticles(): Promise<void> {
|
||||
await articlesApi.loadList();
|
||||
}
|
||||
|
||||
function onToggleArticleSearch(): void {
|
||||
showArticleSearch.value = !showArticleSearch.value;
|
||||
}
|
||||
|
||||
async function onArticleSearch(): Promise<void> {
|
||||
await articlesApi.loadList(articleKeyword.value);
|
||||
}
|
||||
|
||||
function onCreateArticle(): void {
|
||||
// 首页「+」直接弹底部编辑层(不另开页):新建空白整包,保存走 useArticles.save
|
||||
articlesApi.startCreate();
|
||||
articleEditorVisible.value = true;
|
||||
}
|
||||
|
||||
function closeArticleEditor(): void {
|
||||
articleEditorVisible.value = false;
|
||||
void articlesApi.cancelEdit();
|
||||
}
|
||||
|
||||
async function onArticleSaved(): Promise<void> {
|
||||
articleEditorVisible.value = false;
|
||||
await loadArticles();
|
||||
}
|
||||
|
||||
function onOpenArticle(name: string): void {
|
||||
if (!name) {
|
||||
return;
|
||||
}
|
||||
uni.navigateTo({ url: `${pageUrl("articlePage")}?name=${encodeURIComponent(name)}` });
|
||||
}
|
||||
|
||||
// ===== 详情态数据(原 family-tree-operation 全量照搬) =====
|
||||
const data = ref<FamilyTreeData>(new FamilyTreeData());
|
||||
const tree = computed(() => data.value.FamilyTree);
|
||||
@@ -1520,6 +1576,7 @@ defineExpose({
|
||||
reloadIfList(): void {
|
||||
if (!inDetail.value) {
|
||||
loadTrees();
|
||||
void loadArticles();
|
||||
void fetchStorage();
|
||||
}
|
||||
},
|
||||
@@ -1528,7 +1585,8 @@ defineExpose({
|
||||
|
||||
<template>
|
||||
<!-- 列表态显示 tabbar,进详情(或弹层展开)收起;未认证底部弹层也在列表态弹出,须一并隐 tabbar,否则盖住底部按钮 -->
|
||||
<app-page :hide-nav="true" :show-tabbar="!inDetail && !idPopup">
|
||||
<!-- 文章底部编辑层同理:不隐 tabbar 会盖住弹层底部的「取消/保存」(tabbar 与弹层不在同一层叠上下文,z-index 压不过) -->
|
||||
<app-page :hide-nav="true" :show-tabbar="!inDetail && !idPopup && !articleEditorVisible">
|
||||
<view class="tree-page" :style="[{ background: themeBackground }, themeVars]">
|
||||
<!-- ===== 列表态:软底分区面板(标题 + 搜索/新建/伸缩圆钮组 + 白底族谱卡) ===== -->
|
||||
<!-- 隐藏原生滚动条:否则 Chrome 滚动条占右侧 ~17px,面板左右留白不对称 -->
|
||||
@@ -1569,7 +1627,7 @@ defineExpose({
|
||||
<text class="home-card__stitle">{{ t("storage.title") }}</text>
|
||||
</view>
|
||||
<text class="home-card__spct" :class="{ 'home-card__spct--over': storageOver }">{{ storagePercentText
|
||||
}}</text>
|
||||
}}</text>
|
||||
</view>
|
||||
<view class="home-card__bar">
|
||||
<view class="home-card__bar-fill" :class="{ 'home-card__bar-fill--over': storageOver }"
|
||||
@@ -1634,6 +1692,69 @@ defineExpose({
|
||||
</template>
|
||||
</view>
|
||||
</view>
|
||||
<!-- ===== 有家文化传承:与族谱面板并列的文章卡 ===== -->
|
||||
<view class="tree-panel article-panel">
|
||||
<view class="tree-panel__head">
|
||||
<text class="tree-panel__title">{{ t("article.title") }}</text>
|
||||
<view class="tree-panel__acts">
|
||||
<view class="tree-panel__act" @tap="onToggleArticleSearch">
|
||||
<u-icon name="search" size="34" :color="primary" />
|
||||
</view>
|
||||
<view class="tree-panel__act tree-panel__act--add" @tap="onCreateArticle">
|
||||
<u-icon name="plus" size="30" color="#ffffff" />
|
||||
</view>
|
||||
<view class="tree-panel__act" @tap="articleCollapsed = !articleCollapsed">
|
||||
<u-icon :name="articleCollapsed ? 'arrow-up' : 'arrow-down'" size="34" :color="primary" />
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view v-if="showArticleSearch" class="tree-panel__search">
|
||||
<search-input v-model="articleKeyword" :placeholder="t('article.searchPlaceholder')"
|
||||
@search="onArticleSearch" />
|
||||
</view>
|
||||
<view v-if="!articleCollapsed" class="tree-panel__list">
|
||||
<view v-if="articlesApi.loading.value" class="tree-page__state">
|
||||
<text class="tree-page__state-text">{{ t("FilePage.loading") }}</text>
|
||||
</view>
|
||||
<view v-else-if="articlesApi.errorText.value" class="tree-page__state">
|
||||
<text class="tree-page__state-text tree-page__state-text--error">{{ articlesApi.errorText.value }}</text>
|
||||
<text class="tree-page__retry" @click="loadArticles">{{ t("FilePage.retryBut") }}</text>
|
||||
</view>
|
||||
<template v-else>
|
||||
<view v-if="!homeArticles.length" class="tree-page__empty">
|
||||
<text class="tree-page__state-text">{{ t("article.empty") }}</text>
|
||||
</view>
|
||||
<view v-for="row in homeArticles" :key="row.ArticleName" class="article-card"
|
||||
@tap="onOpenArticle(row.ArticleName)">
|
||||
<view class="article-card__cover">
|
||||
<image v-if="row.CoverUrl || row.ArticleCoverFilename" class="article-card__cover-img"
|
||||
:src="absoluteFileUrl(row.CoverUrl) || backupFileUrl('ArticleCover', row.ArticleCoverFilename)"
|
||||
mode="aspectFill" />
|
||||
<image v-else class="article-card__glyph" :src="articleGlyph" mode="aspectFit" />
|
||||
</view>
|
||||
<view class="article-card__main">
|
||||
<view class="article-card__titlerow">
|
||||
<text class="article-card__name">{{ row.ArticleTitle || t("article.unnamed") }}</text>
|
||||
<text v-if="row.TopIndc === 'Y'" class="article-card__chip article-card__chip--top">{{
|
||||
t("article.tagTop") }}</text>
|
||||
<text v-if="row.ApprovedIndc !== 'Y'" class="article-card__chip article-card__chip--pending">{{
|
||||
t("article.tagPending") }}</text>
|
||||
</view>
|
||||
<text class="article-card__desc">{{ articlesApi.plainText(row.Brief, 100) || t("article.noBrief")
|
||||
}}</text>
|
||||
<view class="article-card__meta">
|
||||
<image v-if="row.AuthorAvatarUrl" class="article-card__avatar"
|
||||
:src="absoluteFileUrl(row.AuthorAvatarUrl)" mode="aspectFill" />
|
||||
<view v-else class="article-card__avatar article-card__avatar--empty" />
|
||||
<text class="article-card__author">{{ row.AuthorLocalName || t("article.unknownAuthor") }}</text>
|
||||
<text class="article-card__date">{{ articlesApi.formatDate(row.CreatedDatetime) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<u-icon name="arrow-right" size="30" color="#c0c4cc" />
|
||||
</view>
|
||||
</template>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
|
||||
<!-- ===== 详情态:族谱管理 ===== -->
|
||||
@@ -1665,7 +1786,7 @@ defineExpose({
|
||||
<u-switch v-if="data.IsManager" :model-value="tree.PublicIndc === 'Y'" size="40"
|
||||
:active-color="primary" @change="onPublicToggle" />
|
||||
<text v-if="data.IsManager" class="ft-badge ft-badge--admin">{{ t("FamilyTreePage.managerTag")
|
||||
}}</text>
|
||||
}}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
@@ -1772,7 +1893,7 @@ defineExpose({
|
||||
<!-- 当前族员:从概览移入,夫妻/子女列表都是相对此人 -->
|
||||
<view v-if="loaded && currentPerson" class="ft-card">
|
||||
<view class="ft-card__head"><text class="ft-card__title">{{ t("FamilyTreePage.currentPerson")
|
||||
}}</text></view>
|
||||
}}</text></view>
|
||||
<view class="person-hero">
|
||||
<view class="person-hero__avatar"
|
||||
:style="avatarFallbackStyle(!!currentPerson?.AvatarUrl, currentPerson?.Gender)">
|
||||
@@ -1787,7 +1908,7 @@ defineExpose({
|
||||
</view>
|
||||
<view class="person-hero__chips">
|
||||
<text v-if="currentPerson?.PersonId" class="person-hero__chip">{{ personChipText(currentPerson)
|
||||
}}</text>
|
||||
}}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
@@ -1830,12 +1951,12 @@ defineExpose({
|
||||
<!-- 新增入口与行尾操作同一把闸:当前锚点不可编(未绑定/出支系)时隐藏 -->
|
||||
<view class="ft-card__head">
|
||||
<view class="ft-card__title-group"><text class="ft-card__title">{{ t("FamilyTreePage.spouseTitle")
|
||||
}}</text><text class="ft-card__count">{{ spouseMaps.length }}</text></view>
|
||||
}}</text><text class="ft-card__count">{{ spouseMaps.length }}</text></view>
|
||||
<view v-if="data.IsCanAmend" class="ft-card__add" @tap="openQuickCreate('spouse')"><u-icon
|
||||
name="plus" size="26" :color="primary" /><text>{{ t("FamilyTreePage.addSpouse") }}</text></view>
|
||||
</view>
|
||||
<view v-if="!visibleSpouses.length" class="ft-empty"><text>{{ t("FamilyTreePage.spouseEmpty")
|
||||
}}</text></view>
|
||||
}}</text></view>
|
||||
<view v-for="record in visibleSpouses" :key="record.SpouseId" class="ft-mrow">
|
||||
<view class="ft-av"
|
||||
:style="avatarFallbackStyle(!!personAvatar(record.SpouseId), personGender(record.SpouseId, record.Gender))">
|
||||
@@ -1871,13 +1992,13 @@ defineExpose({
|
||||
<view class="ft-card">
|
||||
<view class="ft-card__head">
|
||||
<view class="ft-card__title-group"><text class="ft-card__title">{{ t("FamilyTreePage.childTitle")
|
||||
}}</text><text class="ft-card__count">{{ childMaps.length }}</text></view>
|
||||
}}</text><text class="ft-card__count">{{ childMaps.length }}</text></view>
|
||||
<view v-if="data.IsCanAmend" class="ft-card__add" @tap="openQuickCreate('child')"><u-icon
|
||||
name="plus" size="26" :color="primary" /><text>{{ t("FamilyTreePage.addChildBtn") }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view v-if="!visibleChildren.length" class="ft-empty"><text>{{ t("FamilyTreePage.childEmpty")
|
||||
}}</text></view>
|
||||
}}</text></view>
|
||||
<view v-for="record in visibleChildren" :key="record.ChildrenId" class="ft-mrow">
|
||||
<view class="ft-av"
|
||||
:style="avatarFallbackStyle(!!personAvatar(record.ChildrenId), personGender(record.ChildrenId, record.Gender))">
|
||||
@@ -1917,7 +2038,7 @@ defineExpose({
|
||||
<view v-if="data.IsManager && pendingPermissions.length" class="ft-card">
|
||||
<view class="ft-card__head">
|
||||
<view class="ft-card__title-group"><text class="ft-card__title">{{ t("FamilyTreePage.permPending")
|
||||
}}</text><text class="ft-card__count">{{ pendingPermissions.length }}</text></view>
|
||||
}}</text><text class="ft-card__count">{{ pendingPermissions.length }}</text></view>
|
||||
</view>
|
||||
<view v-for="record in pendingPermissions" :key="record.UserName" class="ft-perm-row">
|
||||
<view class="ft-perm-row__main"><text class="ft-perm-row__name">{{ record.UserLocalName ||
|
||||
@@ -1941,10 +2062,10 @@ defineExpose({
|
||||
<view v-if="data.IsManager" class="ft-card">
|
||||
<view class="ft-card__head">
|
||||
<view class="ft-card__title-group"><text class="ft-card__title">{{ t("FamilyTreePage.permManager")
|
||||
}}</text><text class="ft-card__count">{{ managerPermissions.length }}</text></view>
|
||||
}}</text><text class="ft-card__count">{{ managerPermissions.length }}</text></view>
|
||||
</view>
|
||||
<view v-if="!managerPermissions.length" class="ft-empty"><text>{{ t("FamilyTreePage.permEmpty")
|
||||
}}</text></view>
|
||||
}}</text></view>
|
||||
<view v-for="record in managerPermissions" :key="record.UserName" class="ft-perm-row">
|
||||
<view class="ft-perm-row__main"><text class="ft-perm-row__name">{{ record.UserLocalName ||
|
||||
record.UserName
|
||||
@@ -2179,7 +2300,7 @@ defineExpose({
|
||||
<view class="person-hero__line">
|
||||
<text class="rel-name">{{ record.SpouseSurname }}{{ record.SpouseGenealogyName }}</text>
|
||||
<text class="person-hero__gender">{{ genderText(personGender(record.SpouseId, record.Gender))
|
||||
}}</text>
|
||||
}}</text>
|
||||
</view>
|
||||
<view class="person-hero__chips">
|
||||
<text class="person-hero__chip">
|
||||
@@ -2216,7 +2337,7 @@ defineExpose({
|
||||
<text class="sub-label">{{ t("FamilyTreePage.marryDate") }}</text>
|
||||
<view class="sub-picker">
|
||||
<text class="sub-picker-text">{{ datePart(record.MarryDate) || t("AdminPage.notSetTip")
|
||||
}}</text>
|
||||
}}</text>
|
||||
<u-icon name="arrow-right" size="24" color="#c4c8cf" />
|
||||
</view>
|
||||
</view>
|
||||
@@ -2232,7 +2353,7 @@ defineExpose({
|
||||
<view class="sub-picker">
|
||||
<text class="sub-picker-text">{{
|
||||
datePart(record.DivorceDate) || t("AdminPage.notSetTip")
|
||||
}}</text>
|
||||
}}</text>
|
||||
<u-icon name="arrow-right" size="24" color="#c4c8cf" />
|
||||
</view>
|
||||
</view>
|
||||
@@ -2318,7 +2439,7 @@ defineExpose({
|
||||
<view class="person-hero__line">
|
||||
<text class="rel-name">{{ record.ChildrenSurname }}{{ record.ChildrenGenealogyName }}</text>
|
||||
<text class="person-hero__gender">{{ genderText(personGender(record.ChildrenId, record.Gender))
|
||||
}}</text>
|
||||
}}</text>
|
||||
</view>
|
||||
<view class="person-hero__chips">
|
||||
<text class="person-hero__chip">
|
||||
@@ -2429,7 +2550,7 @@ defineExpose({
|
||||
<text class="rel-name">{{ record.UserLocalName || record.UserName }}</text>
|
||||
<text class="rel-sub">{{
|
||||
record.ManagerIndc === "Y" ? t("FamilyTreePage.managerTag") : t("FamilyTreePage.memberTag")
|
||||
}}</text>
|
||||
}}</text>
|
||||
</view>
|
||||
<view class="perm-ops">
|
||||
<view class="perm-op perm-op--danger" @tap="rejectPermission(record)">
|
||||
@@ -2606,7 +2727,7 @@ defineExpose({
|
||||
<!-- 服务器文件列表限高滚动:文件多了不挤掉密码框/警告/按钮 -->
|
||||
<scroll-view v-if="!localFileName" scroll-y class="ft-exchange__list">
|
||||
<view v-if="importFilesLoading" class="ft-empty"><text>{{ t("FamilyTreePage.exchangeFileLoading")
|
||||
}}</text></view>
|
||||
}}</text></view>
|
||||
<view v-else-if="!importFiles.length" class="ft-empty"><text>{{ t(imScope === "SUB" ?
|
||||
"FamilyTreePage.exchangeFileEmptySub" : "FamilyTreePage.exchangeFileEmpty") }}</text></view>
|
||||
<view v-for="file in importFiles" :key="file.FileName" class="ft-exchange__row"
|
||||
@@ -2627,7 +2748,7 @@ defineExpose({
|
||||
<view v-if="backupLoading" class="ft-empty"><text>{{ t("FamilyTreePage.exchangeFileLoading") }}</text>
|
||||
</view>
|
||||
<view v-else-if="!backupFiles.length" class="ft-empty"><text>{{ t("FamilyTreePage.exchangeBackupEmpty")
|
||||
}}</text></view>
|
||||
}}</text></view>
|
||||
<view v-for="bk in backupFiles" :key="bk.FileName" class="ft-exchange__row"
|
||||
:class="{ 'ft-exchange__row--on': pickedBackup === bk.FileName }" @tap="pickedBackup = bk.FileName">
|
||||
<view class="ft-exchange__row-main">
|
||||
@@ -2693,11 +2814,15 @@ defineExpose({
|
||||
<view class="ft-sheet__foot">
|
||||
<view class="ft-sheet__btn" @tap="cancelTreeEdit"><text>{{ t("AdminPage.cancelBut") }}</text></view>
|
||||
<view class="ft-sheet__btn ft-sheet__btn--primary" @tap="saveTreeEdit"><text>{{ t("AdminPage.saveBut")
|
||||
}}</text>
|
||||
}}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</u-popup>
|
||||
|
||||
<!-- 有家文化传承:首页「+」新建底部编辑层(不另开页) -->
|
||||
<article-editor :visible="articleEditorVisible" :api="articlesApi" @close="closeArticleEditor"
|
||||
@saved="onArticleSaved" />
|
||||
</view>
|
||||
</app-page>
|
||||
</template>
|
||||
@@ -3264,6 +3389,127 @@ defineExpose({
|
||||
color: #bfc3c9;
|
||||
}
|
||||
|
||||
/* ===== 有家文化传承文章卡(复用 tree-panel 面板几何,卡体换横向图文) ===== */
|
||||
.article-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20rpx;
|
||||
margin: 0 0 20rpx;
|
||||
padding: 24rpx;
|
||||
border-radius: 24rpx;
|
||||
background-color: #ffffff;
|
||||
box-shadow: 0 6rpx 18rpx rgba(31, 35, 41, 0.05);
|
||||
}
|
||||
|
||||
.article-card:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.article-card__cover {
|
||||
display: flex;
|
||||
flex: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 128rpx;
|
||||
height: 128rpx;
|
||||
overflow: hidden;
|
||||
border-radius: 20rpx;
|
||||
background: var(--fg-gradient, linear-gradient(135deg, #4a9dff, #2b8fff));
|
||||
}
|
||||
|
||||
.article-card__cover-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.article-card__glyph {
|
||||
width: 60rpx;
|
||||
height: 60rpx;
|
||||
}
|
||||
|
||||
.article-card__main {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: 8rpx;
|
||||
}
|
||||
|
||||
.article-card__titlerow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10rpx;
|
||||
}
|
||||
|
||||
.article-card__name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 32rpx;
|
||||
font-weight: 600;
|
||||
color: #1f2329;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.article-card__chip {
|
||||
flex: none;
|
||||
padding: 2rpx 12rpx;
|
||||
border-radius: 8rpx;
|
||||
font-size: 20rpx;
|
||||
color: #5c626b;
|
||||
background-color: #f2f3f5;
|
||||
}
|
||||
|
||||
.article-card__chip--top {
|
||||
color: var(--fg-primary, #2b8fff);
|
||||
background-color: var(--fg-soft, #eaf4ff);
|
||||
}
|
||||
|
||||
.article-card__chip--pending {
|
||||
color: #d48806;
|
||||
background-color: #fff7e6;
|
||||
}
|
||||
|
||||
.article-card__desc {
|
||||
font-size: 24rpx;
|
||||
line-height: 34rpx;
|
||||
color: #8f959e;
|
||||
overflow: hidden;
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
}
|
||||
|
||||
.article-card__meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10rpx;
|
||||
}
|
||||
|
||||
.article-card__avatar {
|
||||
flex: none;
|
||||
width: 36rpx;
|
||||
height: 36rpx;
|
||||
border-radius: 50%;
|
||||
background-color: #f2f3f5;
|
||||
}
|
||||
|
||||
.article-card__avatar--empty {
|
||||
background: var(--fg-soft, #eaf4ff);
|
||||
}
|
||||
|
||||
.article-card__author {
|
||||
font-size: 22rpx;
|
||||
color: #5c626b;
|
||||
}
|
||||
|
||||
.article-card__date {
|
||||
margin-left: auto;
|
||||
font-size: 20rpx;
|
||||
color: #bfc3c9;
|
||||
}
|
||||
|
||||
/* ===== 详情头卡 + 圆形动作钮(与共享页同套) ===== */
|
||||
.detail-hero {
|
||||
position: relative;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export * from "./useAdminList";
|
||||
export * from "./useAdminMenu";
|
||||
export * from "./useAdminPick";
|
||||
export * from "./useArticles";
|
||||
export * from "./useAuthGuard";
|
||||
export * from "./useDmsFiles";
|
||||
export * from "./useFamilyGroups";
|
||||
|
||||
@@ -0,0 +1,371 @@
|
||||
/**
|
||||
* 有家文化传承「文章」数据源:列表 / 详情 / 新建 / 编辑 / 保存 / 删除 / 审核 / 置顶 / 群组关联 / 配图。
|
||||
*
|
||||
* 契约(对齐 usePublishAlbums 与后端 ArticleService):
|
||||
* 1. 列表 FindBySearchCriteria -> data.Datas(ArticleDisplayData[]),后端已按「群组成员 + 审核」
|
||||
* 过滤并按置顶、创建时间排序,前端不再筛;
|
||||
* 2. 详情 FindByKey(articleName) -> ArticleData { Article, FamilyGroups[], Filenames[] },
|
||||
* 后端在 Article 上直接下发 IsOwner/IsManager/IsMember 与 AuthorLocalName/AuthorAvatarUrl/CoverUrl;
|
||||
* 3. 编辑一律本地改整包 data,最后 Insert/Update 落库(无独立子表接口);
|
||||
* 4. 保存判定:Article.CreatedBy 有值走 Update,否则 Insert(新编号由后端号段生成);
|
||||
* 5. 封面/配图存物理名,展示地址走 absoluteFileUrl(后端已打包 CoverUrl/ImageUrl,绝对地址幂等)。
|
||||
*/
|
||||
import { useLocale } from "uview-pro";
|
||||
import { computed, ref } from "vue";
|
||||
import { ArticleData } from "@/data/auto/articleData";
|
||||
import { ArticleDisplayData } from "@/data/auto/articleDisplayData";
|
||||
import { ArticleFamilyGroupDisplayData } from "@/data/auto/articleFamilyGroupDisplayData";
|
||||
import { ArticleFilenameDisplayData } from "@/data/auto/articleFilenameDisplayData";
|
||||
import { ArticleSearchCriteria } from "@/data/auto/articleSearchCriteria";
|
||||
import { SearchFilter } from "@/data/auto/searchFilter";
|
||||
import articleService from "@/service/master/articleService";
|
||||
import {
|
||||
absoluteFileUrl,
|
||||
backupFileUrl,
|
||||
mapBriefImageSrc,
|
||||
} from "@/utils/fileUrl";
|
||||
|
||||
/** 列表一次拉全(移动端分页在文章场景暂不需要) */
|
||||
const FIND_LIMIT = 999999;
|
||||
|
||||
/** 是/否标志(与后端 YesNoPolicy 对齐) */
|
||||
export const YES = "Y";
|
||||
export const NO = "N";
|
||||
|
||||
/** 创建日期只取年月日:卡片展示用 */
|
||||
export function formatArticleDate(value: Date | string | undefined): string {
|
||||
if (!value) {
|
||||
return "";
|
||||
}
|
||||
const time =
|
||||
typeof value === "string" ? new Date(value).getTime() : value.getTime();
|
||||
if (!time) {
|
||||
return "";
|
||||
}
|
||||
const d = new Date(time);
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
||||
}
|
||||
|
||||
/** 富文本 HTML 去标签取纯文本,供列表摘要截断 */
|
||||
export function plainTextOf(html: string | undefined, max = 100): string {
|
||||
const text = String(html ?? "")
|
||||
.replace(/<[^>]*>/g, "")
|
||||
.replace(/ /gi, " ")
|
||||
.replace(/&/gi, "&")
|
||||
.replace(/</gi, "<")
|
||||
.replace(/>/gi, ">")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
return text.length > max ? `${text.slice(0, max)}…` : text;
|
||||
}
|
||||
|
||||
/** 插图限宽样式:rich-text / editor 里的 <img> 默认按原图尺寸渲染,手机屏宽不够就横向撑爆 */
|
||||
const FIT_IMG_STYLE = "max-width:100%;height:auto;display:block;";
|
||||
|
||||
/** 给正文 HTML 里所有 <img> 烘上限宽内联样式(跨端统一口径)。
|
||||
* scoped 样式穿透不了 rich-text 内容,只能改 HTML 本身;已存 style 的合并并去掉旧尺寸约束。
|
||||
* 在灌编辑器前调用:样式随 getContents 回写进 Brief 落库,只读态与重开编辑同一效果。 */
|
||||
export function fitBriefImages(html: string | undefined): string {
|
||||
return String(html ?? "").replace(/<img\b[^>]*>/gi, (tag) => {
|
||||
// 去掉旧的宽高属性与 style 里的 max-width/height,再统一烘上限宽样式
|
||||
const cleaned = tag
|
||||
.replace(/\s(?:width|height)="[^"]*"/gi, "")
|
||||
.replace(/\sstyle="([^"]*)"/gi, (_m, s: string) => {
|
||||
const rest = s
|
||||
.replace(/max-width:[^;"]*;?/gi, "")
|
||||
.replace(/height:[^;"]*;?/gi, "")
|
||||
.trim();
|
||||
return rest ? ` style="${rest}"` : "";
|
||||
});
|
||||
return /\sstyle="/i.test(cleaned)
|
||||
? cleaned.replace(/\sstyle="/i, ` style="${FIT_IMG_STYLE}`)
|
||||
: cleaned.replace(/^<img/i, `<img style="${FIT_IMG_STYLE}"`);
|
||||
});
|
||||
}
|
||||
|
||||
/** 展示用 Brief:限图宽 + 把内联 img src 归一到当前端可达绝对地址(只读 rich-text 与编辑器共用,修 App 上正文插图不显示) */
|
||||
export function briefForDisplay(html?: string | null): string {
|
||||
return mapBriefImageSrc(fitBriefImages(String(html ?? "")), "display");
|
||||
}
|
||||
|
||||
/** 存库用 Brief:限图宽 + 把内联 img src 剥成 host 无关相对路径 /FileServer/...(跨环境可移植) */
|
||||
export function briefForStore(html: string): string {
|
||||
return mapBriefImageSrc(fitBriefImages(html), "store");
|
||||
}
|
||||
|
||||
export function useArticles() {
|
||||
const { t } = useLocale();
|
||||
|
||||
// ===== 列表态 =====
|
||||
const list = ref<ArticleDisplayData[]>([]);
|
||||
const loading = ref(false);
|
||||
const errorText = ref("");
|
||||
|
||||
// ===== 详情 / 编辑态 =====
|
||||
const detail = ref<ArticleData | null>(null);
|
||||
const creating = ref(false);
|
||||
const isEdit = ref(false);
|
||||
|
||||
const article = computed<ArticleDisplayData | null>(
|
||||
() => detail.value?.Article ?? null,
|
||||
);
|
||||
const groups = computed<ArticleFamilyGroupDisplayData[]>(
|
||||
() => detail.value?.FamilyGroups ?? [],
|
||||
);
|
||||
const filenames = computed<ArticleFilenameDisplayData[]>(
|
||||
() => detail.value?.Filenames ?? [],
|
||||
);
|
||||
|
||||
const inDetail = computed(
|
||||
() => !!article.value?.ArticleName || creating.value,
|
||||
);
|
||||
/** 归属:后端 FindByKey 下发 IsOwner/IsManager/IsMember;新建态恒为作者 */
|
||||
const isOwner = computed(() => creating.value || !!article.value?.IsOwner);
|
||||
const isGroupManager = computed(
|
||||
() => !creating.value && !!article.value?.IsManager,
|
||||
);
|
||||
const isGroupMember = computed(
|
||||
() => !creating.value && !!article.value?.IsMember,
|
||||
);
|
||||
/** 可管理(改正文/封面/群组/审核/置顶/删除):作者或群管理员 */
|
||||
const canManage = computed(
|
||||
() => inDetail.value && (isOwner.value || isGroupManager.value),
|
||||
);
|
||||
/** 可删除:作者或群管理员 */
|
||||
const canDelete = computed(() => canManage.value);
|
||||
|
||||
// ===== 列表 =====
|
||||
async function loadList(keyword = ""): Promise<void> {
|
||||
loading.value = true;
|
||||
errorText.value = "";
|
||||
try {
|
||||
const criteria = new ArticleSearchCriteria();
|
||||
const filters: SearchFilter[] = [];
|
||||
if (keyword.trim()) {
|
||||
const kw = new SearchFilter();
|
||||
kw.Column = ArticleDisplayData.ARTICLE_TITLE;
|
||||
kw.Type = "text";
|
||||
kw.Operator = "contain";
|
||||
kw.Value = keyword.trim();
|
||||
filters.push(kw);
|
||||
}
|
||||
criteria.Filter = filters;
|
||||
criteria.limit = FIND_LIMIT;
|
||||
criteria.page = 1;
|
||||
criteria.GridColumns = [];
|
||||
const data = await articleService.FindBySearchCriteria(criteria);
|
||||
list.value = (data?.Datas ?? []) as ArticleDisplayData[];
|
||||
} catch (error: any) {
|
||||
list.value = [];
|
||||
errorText.value = error?.message || t("FilePage.loadFailed");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 进详情 =====
|
||||
async function openArticle(articleName: string): Promise<void> {
|
||||
loading.value = true;
|
||||
errorText.value = "";
|
||||
try {
|
||||
const data = await articleService.FindByKey(articleName);
|
||||
if (!data?.Article?.ArticleName) {
|
||||
detail.value = null;
|
||||
errorText.value = t("MediaPage.noAccess");
|
||||
return;
|
||||
}
|
||||
data.FamilyGroups = data.FamilyGroups ?? [];
|
||||
data.Filenames = data.Filenames ?? [];
|
||||
detail.value = data;
|
||||
} catch (error: any) {
|
||||
errorText.value = error?.message || t("FilePage.loadFailed");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function exitDetail(): Promise<void> {
|
||||
detail.value = null;
|
||||
creating.value = false;
|
||||
isEdit.value = false;
|
||||
await loadList();
|
||||
}
|
||||
|
||||
// ===== 新建 / 编辑 =====
|
||||
function startCreate(): void {
|
||||
const blank = new ArticleData();
|
||||
blank.FamilyGroups = [];
|
||||
blank.Filenames = [];
|
||||
blank.Article.ApprovedIndc = NO;
|
||||
blank.Article.TopIndc = NO;
|
||||
detail.value = blank;
|
||||
creating.value = true;
|
||||
isEdit.value = true;
|
||||
}
|
||||
|
||||
function startEdit(): void {
|
||||
isEdit.value = true;
|
||||
}
|
||||
|
||||
async function cancelEdit(): Promise<void> {
|
||||
if (creating.value) {
|
||||
await exitDetail();
|
||||
return;
|
||||
}
|
||||
// 整包已空(如弹层关闭事件二次触发、exitDetail 后 detail 已 null)直接返回:
|
||||
// 否则 article.value!.ArticleName 会踩空抛 "Cannot read properties of null (reading 'ArticleName')"
|
||||
const name = article.value?.ArticleName;
|
||||
if (!name) {
|
||||
return;
|
||||
}
|
||||
isEdit.value = false;
|
||||
await openArticle(name);
|
||||
}
|
||||
|
||||
// ===== 群组关联本地增删(保存整包落库)=====
|
||||
/** 群组行规整:后端 ArticleFamilyGroupEntity 的 CreatedBy/ArticleName/UpdatedBy 为非空 string,
|
||||
* 缺键会触发模型校验 400,补空串让新增行既过校验又能被后端按 FamilyName 差量落库 */
|
||||
function normalizeGroups(
|
||||
rows: ArticleFamilyGroupDisplayData[],
|
||||
): ArticleFamilyGroupDisplayData[] {
|
||||
return rows.map((row) => ({
|
||||
...row,
|
||||
CreatedBy: row.CreatedBy ?? "",
|
||||
ArticleName: row.ArticleName ?? "",
|
||||
UpdatedBy: row.UpdatedBy ?? "",
|
||||
}));
|
||||
}
|
||||
|
||||
function addGroup(familyName: string, familyLocalName = ""): void {
|
||||
if (!detail.value) {
|
||||
return;
|
||||
}
|
||||
const rows = detail.value.FamilyGroups;
|
||||
if (rows.some((g) => g.FamilyName === familyName)) {
|
||||
return;
|
||||
}
|
||||
const row = new ArticleFamilyGroupDisplayData();
|
||||
row.FamilyName = familyName;
|
||||
row.FamilyLocalName = familyLocalName;
|
||||
rows.push(row);
|
||||
}
|
||||
|
||||
function removeGroup(familyName: string): void {
|
||||
if (!detail.value) {
|
||||
return;
|
||||
}
|
||||
detail.value.FamilyGroups = detail.value.FamilyGroups.filter(
|
||||
(g) => g.FamilyName !== familyName,
|
||||
);
|
||||
}
|
||||
|
||||
// ===== 配图本地增删 =====
|
||||
function addImage(physicalName: string): void {
|
||||
if (!detail.value || !physicalName) {
|
||||
return;
|
||||
}
|
||||
const rows = detail.value.Filenames;
|
||||
if (rows.some((f) => f.FileName === physicalName)) {
|
||||
return;
|
||||
}
|
||||
const row = new ArticleFilenameDisplayData();
|
||||
row.FileName = physicalName;
|
||||
rows.push(row);
|
||||
}
|
||||
|
||||
function removeImage(physicalName: string): void {
|
||||
if (!detail.value) {
|
||||
return;
|
||||
}
|
||||
detail.value.Filenames = detail.value.Filenames.filter(
|
||||
(f) => f.FileName !== physicalName,
|
||||
);
|
||||
}
|
||||
|
||||
// ===== 保存 / 删除 / 即时开关 =====
|
||||
async function save(): Promise<void> {
|
||||
const submitted = detail.value!;
|
||||
const entity = submitted.Article;
|
||||
entity.ArticleTitle = (entity.ArticleTitle || "").trim();
|
||||
submitted.FamilyGroups = normalizeGroups(submitted.FamilyGroups ?? []);
|
||||
submitted.Filenames = submitted.Filenames ?? [];
|
||||
const result = entity.CreatedBy
|
||||
? await articleService.Update(submitted)
|
||||
: await articleService.Insert(submitted);
|
||||
result.FamilyGroups = result.FamilyGroups ?? [];
|
||||
result.Filenames = result.Filenames ?? [];
|
||||
detail.value = result;
|
||||
creating.value = false;
|
||||
isEdit.value = false;
|
||||
}
|
||||
|
||||
async function remove(): Promise<void> {
|
||||
const name = article.value?.ArticleName;
|
||||
if (!name) {
|
||||
return;
|
||||
}
|
||||
await articleService.Delete(name);
|
||||
await exitDetail();
|
||||
}
|
||||
|
||||
/** 行内即时切换审核/置顶:改本地整包后立即 save 落库(对齐相册即时保存模式) */
|
||||
async function setFlag(
|
||||
field: "ApprovedIndc" | "TopIndc",
|
||||
on: boolean,
|
||||
): Promise<void> {
|
||||
if (!detail.value?.Article) {
|
||||
return;
|
||||
}
|
||||
detail.value.Article[field] = on ? YES : NO;
|
||||
await save();
|
||||
}
|
||||
|
||||
/** 封面展示地址:已保存态用后端打包的 CoverUrl(绝对地址幂等);
|
||||
* 未保存的新上传只有裸物理名,按落盘口径 /FileServer/Backup/ArticleCover/{ext}/{name} 拼直链 */
|
||||
const coverUrl = computed(() =>
|
||||
article.value?.CoverUrl
|
||||
? absoluteFileUrl(article.value.CoverUrl)
|
||||
: backupFileUrl("ArticleCover", article.value?.ArticleCoverFilename),
|
||||
);
|
||||
|
||||
return {
|
||||
// state
|
||||
list,
|
||||
loading,
|
||||
errorText,
|
||||
detail,
|
||||
article,
|
||||
groups,
|
||||
filenames,
|
||||
coverUrl,
|
||||
inDetail,
|
||||
creating,
|
||||
isEdit,
|
||||
isOwner,
|
||||
isGroupManager,
|
||||
isGroupMember,
|
||||
canManage,
|
||||
canDelete,
|
||||
// list / detail
|
||||
loadList,
|
||||
openArticle,
|
||||
exitDetail,
|
||||
// create / edit
|
||||
startCreate,
|
||||
startEdit,
|
||||
cancelEdit,
|
||||
// groups / images
|
||||
addGroup,
|
||||
removeGroup,
|
||||
addImage,
|
||||
removeImage,
|
||||
// save / delete / flags
|
||||
save,
|
||||
remove,
|
||||
setFlag,
|
||||
// helpers
|
||||
formatDate: formatArticleDate,
|
||||
plainText: plainTextOf,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
|
||||
import { ArticleDisplayData } from "./articleDisplayData";
|
||||
import { ArticleFamilyGroupDisplayData } from "./articleFamilyGroupDisplayData";
|
||||
import { ArticleFilenameDisplayData } from "./articleFilenameDisplayData";
|
||||
export class ArticleData
|
||||
{
|
||||
public Article: ArticleDisplayData = new ArticleDisplayData();
|
||||
public FamilyGroups: ArticleFamilyGroupDisplayData[] = [];
|
||||
public Filenames: ArticleFilenameDisplayData[] = [];
|
||||
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { ArticleEntity } from "./articleEntity";
|
||||
export class ArticleDisplayData extends ArticleEntity {
|
||||
public AuthorLocalName: string = "";
|
||||
public AuthorAvatarUrl: string = "";
|
||||
public CoverUrl: string = "";
|
||||
public IsOwner: boolean = false;
|
||||
public IsMember: boolean = false;
|
||||
public IsManager: boolean = false;
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
|
||||
export class ArticleEntity
|
||||
{
|
||||
public CreatedDatetime: Date = new Date();
|
||||
public CreatedBy: string = "";
|
||||
public UpdatedDatetime: Date = new Date();
|
||||
public UpdatedBy: string = "";
|
||||
public ArticleName: string = "";
|
||||
public ArticleTitle: string = "";
|
||||
public Brief: string = "";
|
||||
public ArticleCoverFilename: string = "";
|
||||
public ApprovedIndc: string = "";
|
||||
public TopIndc: string = "";
|
||||
public OrderBy: number = 0;
|
||||
|
||||
static readonly CREATED_DATETIME : string ='CreatedDatetime';
|
||||
static readonly CREATED_BY : string ='CreatedBy';
|
||||
static readonly UPDATED_DATETIME : string ='UpdatedDatetime';
|
||||
static readonly UPDATED_BY : string ='UpdatedBy';
|
||||
static readonly ARTICLE_NAME : string ='ArticleName';
|
||||
static readonly ARTICLE_TITLE : string ='ArticleTitle';
|
||||
static readonly BRIEF : string ='Brief';
|
||||
static readonly ARTICLE_COVER_FILENAME : string ='ArticleCoverFilename';
|
||||
static readonly APPROVED_INDC : string ='ApprovedIndc';
|
||||
static readonly TOP_INDC : string ='TopIndc';
|
||||
static readonly ORDER_BY : string ='OrderBy';
|
||||
static readonly DB_NAME_ARTICLE : string ='article';
|
||||
static readonly DB_NAME_FIELDS : string ='created_datetime,created_by,updated_datetime,updated_by,article_name,article_title,brief,article_cover_filename,approved_indc,top_indc,order_by';
|
||||
static readonly DB_CREATED_DATETIME : string ='created_datetime';
|
||||
static readonly DB_CREATED_BY : string ='created_by';
|
||||
static readonly DB_UPDATED_DATETIME : string ='updated_datetime';
|
||||
static readonly DB_UPDATED_BY : string ='updated_by';
|
||||
static readonly DB_ARTICLE_NAME : string ='article_name';
|
||||
static readonly DB_ARTICLE_TITLE : string ='article_title';
|
||||
static readonly DB_BRIEF : string ='brief';
|
||||
static readonly DB_ARTICLE_COVER_FILENAME : string ='article_cover_filename';
|
||||
static readonly DB_APPROVED_INDC : string ='approved_indc';
|
||||
static readonly DB_TOP_INDC : string ='top_indc';
|
||||
static readonly DB_ORDER_BY : string ='order_by';
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
|
||||
import { ArticleFamilyGroupEntity } from "./articleFamilyGroupEntity";
|
||||
export class ArticleFamilyGroupDisplayData extends ArticleFamilyGroupEntity
|
||||
{
|
||||
public FamilyLocalName: string = "";
|
||||
public Notice: string = "";
|
||||
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
|
||||
export class ArticleFamilyGroupEntity
|
||||
{
|
||||
public CreatedDatetime: Date = new Date();
|
||||
public CreatedBy: string = "";
|
||||
public UpdatedDatetime: Date = new Date();
|
||||
public UpdatedBy: string = "";
|
||||
public ArticleName: string = "";
|
||||
public FamilyName: string = "";
|
||||
|
||||
static readonly CREATED_DATETIME : string ='CreatedDatetime';
|
||||
static readonly CREATED_BY : string ='CreatedBy';
|
||||
static readonly UPDATED_DATETIME : string ='UpdatedDatetime';
|
||||
static readonly UPDATED_BY : string ='UpdatedBy';
|
||||
static readonly ARTICLE_NAME : string ='ArticleName';
|
||||
static readonly FAMILY_NAME : string ='FamilyName';
|
||||
static readonly DB_NAME_ARTICLE_FAMILY_GROUP : string ='article_family_group';
|
||||
static readonly DB_NAME_FIELDS : string ='created_datetime,created_by,updated_datetime,updated_by,article_name,family_name';
|
||||
static readonly DB_CREATED_DATETIME : string ='created_datetime';
|
||||
static readonly DB_CREATED_BY : string ='created_by';
|
||||
static readonly DB_UPDATED_DATETIME : string ='updated_datetime';
|
||||
static readonly DB_UPDATED_BY : string ='updated_by';
|
||||
static readonly DB_ARTICLE_NAME : string ='article_name';
|
||||
static readonly DB_FAMILY_NAME : string ='family_name';
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
|
||||
export class ArticleFamilyGroupEntityKey
|
||||
{
|
||||
public ArticleName: string = "";
|
||||
public FamilyName: string = "";
|
||||
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import { ArticleFilenameEntity } from "./articleFilenameEntity";
|
||||
export class ArticleFilenameDisplayData extends ArticleFilenameEntity {
|
||||
public ImageUrl: string = "";
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
|
||||
export class ArticleFilenameEntity
|
||||
{
|
||||
public CreatedDatetime: Date = new Date();
|
||||
public CreatedBy: string = "";
|
||||
public UpdatedDatetime: Date = new Date();
|
||||
public UpdatedBy: string = "";
|
||||
public FileId: number = 0;
|
||||
public ArticleName: string = "";
|
||||
public FileName: string = "";
|
||||
|
||||
static readonly CREATED_DATETIME : string ='CreatedDatetime';
|
||||
static readonly CREATED_BY : string ='CreatedBy';
|
||||
static readonly UPDATED_DATETIME : string ='UpdatedDatetime';
|
||||
static readonly UPDATED_BY : string ='UpdatedBy';
|
||||
static readonly FILE_ID : string ='FileId';
|
||||
static readonly ARTICLE_NAME : string ='ArticleName';
|
||||
static readonly FILE_NAME : string ='FileName';
|
||||
static readonly DB_NAME_ARTICLE_FILENAME : string ='article_filename';
|
||||
static readonly DB_NAME_FIELDS : string ='created_datetime,created_by,updated_datetime,updated_by,file_id,article_name,file_name';
|
||||
static readonly DB_CREATED_DATETIME : string ='created_datetime';
|
||||
static readonly DB_CREATED_BY : string ='created_by';
|
||||
static readonly DB_UPDATED_DATETIME : string ='updated_datetime';
|
||||
static readonly DB_UPDATED_BY : string ='updated_by';
|
||||
static readonly DB_FILE_ID : string ='file_id';
|
||||
static readonly DB_ARTICLE_NAME : string ='article_name';
|
||||
static readonly DB_FILE_NAME : string ='file_name';
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
|
||||
import { ArticleDisplayData } from "./articleDisplayData";
|
||||
import { SearchCriteriaResultData } from "./searchCriteriaResultData";
|
||||
export class ArticleResultData extends SearchCriteriaResultData
|
||||
{
|
||||
public Datas: ArticleDisplayData[] = [];
|
||||
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
|
||||
import { AbstractSearchCriteria } from "./abstractSearchCriteria";
|
||||
export class ArticleSearchCriteria extends AbstractSearchCriteria
|
||||
{
|
||||
|
||||
}
|
||||
@@ -12,6 +12,46 @@
|
||||
"avatarUploadedTip": "Avatar updated",
|
||||
"avatarUploadFailed": "Avatar upload failed"
|
||||
},
|
||||
"article": {
|
||||
"title": "Family Culture",
|
||||
"searchPlaceholder": "Search article title",
|
||||
"empty": "No culture articles yet",
|
||||
"unnamed": "Untitled article",
|
||||
"noBrief": "No content",
|
||||
"unknownAuthor": "Youjiala user",
|
||||
"tagTop": "Pinned",
|
||||
"tagPending": "Pending",
|
||||
"editTitle": "Edit Article",
|
||||
"fieldTitle": "Title",
|
||||
"titleRequired": "Please enter an article title",
|
||||
"fieldCover": "Cover",
|
||||
"coverPick": "Tap to upload cover",
|
||||
"fieldContent": "Content",
|
||||
"fmtHeader": "Heading",
|
||||
"fmtList": "List",
|
||||
"fmtImage": "Image",
|
||||
"fieldImages": "Images",
|
||||
"addImage": "Add image",
|
||||
"noImage": "No images",
|
||||
"imageTapHint": "Tap a thumbnail to insert it into the content again",
|
||||
"imageInserted": "Inserted into content",
|
||||
"editorNotReady": "Editor not ready yet, please retry",
|
||||
"fieldGroups": "Groups",
|
||||
"addGroup": "Add group",
|
||||
"noGroup": "No linked group",
|
||||
"noGroupCandidate": "No group available to link",
|
||||
"uploadFailed": "Upload failed",
|
||||
"tabOverview": "Overview",
|
||||
"tabImages": "Images",
|
||||
"tabGroups": "Groups",
|
||||
"fieldApproved": "Approved",
|
||||
"fieldTop": "Pin to top",
|
||||
"delete": "Delete",
|
||||
"cancel": "Cancel",
|
||||
"save": "Save",
|
||||
"deleteTitle": "Delete Article",
|
||||
"deleteConfirm": "Delete this article and its linked records?"
|
||||
},
|
||||
"LoginForm": {
|
||||
"userLabel": "Username",
|
||||
"passwordLabel": "Password",
|
||||
|
||||
@@ -12,6 +12,46 @@
|
||||
"avatarUploadedTip": "头像已更新",
|
||||
"avatarUploadFailed": "头像上传失败"
|
||||
},
|
||||
"article": {
|
||||
"title": "有家文化传承",
|
||||
"searchPlaceholder": "搜索文章标题",
|
||||
"empty": "还没有文化传承文章",
|
||||
"unnamed": "未命名文章",
|
||||
"noBrief": "暂无内容",
|
||||
"unknownAuthor": "有家用户",
|
||||
"tagTop": "置顶",
|
||||
"tagPending": "待审核",
|
||||
"editTitle": "编辑文章",
|
||||
"fieldTitle": "标题",
|
||||
"titleRequired": "请输入文章标题",
|
||||
"fieldCover": "封面",
|
||||
"coverPick": "点击上传封面",
|
||||
"fieldContent": "正文",
|
||||
"fmtHeader": "标题",
|
||||
"fmtList": "列表",
|
||||
"fmtImage": "插图",
|
||||
"fieldImages": "配图",
|
||||
"addImage": "添加配图",
|
||||
"noImage": "暂无配图",
|
||||
"imageTapHint": "点击缩略图可重新插入正文",
|
||||
"imageInserted": "已插入正文",
|
||||
"editorNotReady": "编辑器未就绪,请稍候再试",
|
||||
"fieldGroups": "关联群组",
|
||||
"addGroup": "添加群组",
|
||||
"noGroup": "未关联任何群组",
|
||||
"noGroupCandidate": "没有可关联的群组",
|
||||
"uploadFailed": "上传失败",
|
||||
"tabOverview": "概览",
|
||||
"tabImages": "图片",
|
||||
"tabGroups": "群组",
|
||||
"fieldApproved": "已审核",
|
||||
"fieldTop": "置顶",
|
||||
"delete": "删除",
|
||||
"cancel": "取消",
|
||||
"save": "保存",
|
||||
"deleteTitle": "删除文章",
|
||||
"deleteConfirm": "确定删除这篇文章及其关联记录吗?"
|
||||
},
|
||||
"LoginForm": {
|
||||
"userLabel": "用户名",
|
||||
"passwordLabel": "密码",
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
# 有家文化传承(Article)功能归档
|
||||
|
||||
> 适用范围:「有家文化传承」(文章 / Article)从**数据模型、编号、权限可见性、后端服务、文件落盘与地址、前端编排、富文本编辑器、详情页 UI** 的整条链路。
|
||||
> 本文是该特性的单一事实来源(single source of truth)。
|
||||
> 涉及工程:后端 `CloudBuilder`(.NET 8 / Furion,`CloudBuilder.Master` + `CloudBuilder.Master.Service`)、前端 `uniapp youjiala`(Vue3 + uni-app + uview-pro)。
|
||||
> 相关文档:文件地址归一见 [file-url.md](./file-url.md),权限体系见 [permission.md](./permission.md),页面路由见 [page-routing.md](./page-routing.md)。
|
||||
|
||||
---
|
||||
|
||||
## 1. 一句话概览
|
||||
|
||||
一篇「文化传承」文章 = 主表 `article` + 关联的族谱群组 `article_family_group`(决定谁可见)+ 配图明细 `article_filename`。作者撰写并挂群组,**群组正式成员可读**,**未审核文章仅作者本人可见**;作者/群管理员可改正文、封面、配图、审核与置顶。首页以卡片列表呈现,详情页分「管理态 / 只读态」两副面孔,编辑走富文本弹层。
|
||||
|
||||
---
|
||||
|
||||
## 2. 数据模型
|
||||
|
||||
三张表,主键均为业务编号 `article_name`(号段生成,见第 3 节)。
|
||||
|
||||
### 2.1 主表 `article`(`CloudBuilder.Master/Entity/ArticleEntity.auto.cs`)
|
||||
|
||||
| 字段(DB) | 属性 | 含义 | 备注 |
|
||||
|---|---|---|---|
|
||||
| `article_name` | `ArticleName` | 主键编号 | 号段生成,见 §3 |
|
||||
| `article_title` | `ArticleTitle` | 标题 | 首页搜索按此模糊 |
|
||||
| `brief` | `Brief` | 正文富文本 HTML | `<img>` 内联烘上限宽样式,见 §8.2 |
|
||||
| `article_cover_filename` | `ArticleCoverFilename` | 封面**物理文件名** | 非 URL;展示时打包,见 §6 |
|
||||
| `approved_indc` | `ApprovedIndc` | 是否已审核 `Y/N` | 新文章默认 `N` |
|
||||
| `top_indc` | `TopIndc` | 是否置顶 `Y/N` | 新文章默认 `N` |
|
||||
| `order_by` | `OrderBy` (int) | 排序值 | 升序,值小靠前;见 §5 |
|
||||
| `created_by` / `created_datetime` | | 作者账号名 / 建单时间 | 作者中文名、头像由此 join 回填 |
|
||||
| `updated_by` / `updated_datetime` | | 最后更新人 / 时间 | |
|
||||
|
||||
### 2.2 群组关联 `article_family_group`(`ArticleFamilyGroupEntity`)
|
||||
|
||||
一行一条「文章 ↔ 族谱群组」关系:`ArticleName` + `FamilyName`。决定文章对哪些群组成员可见。增删走差量(`SaveArticleFamilyGroup`)。
|
||||
|
||||
### 2.3 配图明细 `article_filename`(`ArticleFilenameEntity.auto.cs`)
|
||||
|
||||
一行一张配图:`ArticleName` + `FileName`(物理名,可空)+ `FileId`(明细序号,按此升序返回)。`CreatedBy/UpdatedBy` 非空。增删走差量(`SaveArticleFilenames`),**仅作者/群管理员落子表**。
|
||||
|
||||
---
|
||||
|
||||
## 3. 编号生成
|
||||
|
||||
`article_name` 走号段生成器 `ISecurityGeneratorNumberService.GetNo`(`CloudBuilder.Master/Policy/MasterPrefixPolicy.cs`):
|
||||
|
||||
| 常量 | 值 |
|
||||
|---|---|
|
||||
| `ARTICLE` / `ARTICLE_ARTICLE_NAME` | `ARTICLE` / `ARTICLE_NAME`(号段键) |
|
||||
| `ARTICLE_PREFIX` | `PP` |
|
||||
| `ARTICLE_FORMAT` | `yymmdd00000` |
|
||||
|
||||
即与相册同格式但**独立计数**,样例 `PP26102200001`。
|
||||
|
||||
---
|
||||
|
||||
## 4. 权限与可见性(核心闸门)
|
||||
|
||||
权限策略名 `MasterPermissionPolicy.Article = "Article"`,四操作 `Find/Insert/Update/Delete`,标注在 `ArticleService.auto.cs` 各方法上(`[PermissionOperate]`)。
|
||||
|
||||
### 4.1 三档身份(`FindByKey` / `Update` 内计算)
|
||||
|
||||
| 身份 | 判定 | 能力 |
|
||||
|---|---|---|
|
||||
| **作者 isOwner** | `article.created_by == 当前 userId` | 全权:读/改正文/封面/配图/群组/审核/置顶/删 |
|
||||
| **群管理员 isManager** | 关联群组的 `family_group_manager` 行 `ValidIndc=Y` 且 `ManagerIndc=Y` | 改信息/群组/审核/置顶、删;**非作者** |
|
||||
| **群成员 isMember** | 关联群组的 `family_group_manager` 行 `ValidIndc=Y` | 只读(且文章须已审核) |
|
||||
|
||||
`isOwner` 时 `isMember`/`isManager` 直接置真(作者天然全权)。
|
||||
|
||||
### 4.2 单篇读取闸门(`FindByKey`)
|
||||
|
||||
```
|
||||
非作者且非成员 → throw ValidatedException("没有权限.")
|
||||
非作者且未审核(ApprovedIndc≠Y) → throw ValidatedException("文章尚未审核通过.")
|
||||
```
|
||||
|
||||
### 4.3 列表可见性(`FindBySearchCriteria`)
|
||||
|
||||
先取「我加入的正式群组」`myGroups`(`family_group_manager.ValidIndc=Y`),再取「这些群组下挂的文章编号」`articleNamesInMyGroups`,过滤:
|
||||
|
||||
```
|
||||
(CreatedBy == 我 OR articleNamesInMyGroups.Contains(ArticleName))
|
||||
AND (ApprovedIndc == Y OR CreatedBy == 我)
|
||||
```
|
||||
|
||||
**结论:未审核文章只有作者本人能在列表看到**——这是设计闸门,不是 bug。要让普通成员(如 003)看到某篇文章,作者/群管理员须把该文章的「已审核」开关打开。
|
||||
|
||||
### 4.4 改/删的字段级防护(`Update`)
|
||||
|
||||
- 非作者:`CreatedBy`/`CreatedDatetime` 一律以库为准(防客户端伪造归属)。
|
||||
- 普通成员(非 owner 且非 manager):`ArticleTitle/Brief/ArticleCoverFilename/ApprovedIndc/TopIndc/OrderBy` 全部回灌库中值——**改不动**。
|
||||
- 群组关联与配图子表:仅 `isOwner || isManager` 才调用 `SaveArticleFamilyGroup` / `SaveArticleFilenames`。
|
||||
- `Delete`:作者或任一关联群管理员,级联删群组与配图子表行。
|
||||
|
||||
---
|
||||
|
||||
## 5. 排序规则
|
||||
|
||||
列表排序在服务端 LINQ 硬编码(`FindBySearchCriteria`):
|
||||
|
||||
```csharp
|
||||
.OrderByDescending(x => x.TopIndc == YesNoPolicy.YES) // 置顶优先
|
||||
.ThenBy(x => x.OrderBy) // 排序值升序(CloudBuilder 约定值小靠前)
|
||||
.ThenByDescending(x => x.CreatedDatetime); // 创建时间倒序兜底
|
||||
```
|
||||
|
||||
即「**置顶 + 排序组合**」。`OrderBy` 升序与明细列表、Locale 列表口径一致;时间倒序仅作同 `order_by`(如都为 0)时的稳定兜底。
|
||||
|
||||
> ⚠️ **已知边界**:App 内暂无 `order_by` 编辑入口(管理态只有「置顶」「已审核」两个开关),故当前所有文章 `order_by=0`,排序组合实际退化为「置顶 + 时间倒序」。要启用「排序」这一级,需在 CloudBuilder 管理 GUI / 数据库设 `order_by`,或在管理态面板补一个「排序值」数字输入框(走即时保存,与置顶开关同机制)。
|
||||
|
||||
---
|
||||
|
||||
## 6. 文件落盘与访问地址
|
||||
|
||||
封面与配图**存物理文件名**(裸名如 `uni-1792639791154-1-1.jpg`),不存 URL;展示时按上传目录打包。
|
||||
|
||||
### 6.1 落盘目录常量(`CloudBuilder.Master/Policy/UploadFolderPolicy.cs`)
|
||||
|
||||
| 常量 | 值 | 对应 |
|
||||
|---|---|---|
|
||||
| `ARTICLE_COVER` | `ArticleCover` | `article.article_cover_filename` |
|
||||
| `ARTICLE_IMAGE` | `ArticleImage` | `article_filename.file_name` |
|
||||
|
||||
前端上传时 `FilePath` 头须与之一致。
|
||||
|
||||
### 6.2 后端打包(`ArticleService.PackCover / PackImage`)
|
||||
|
||||
复用头像同机制 `FileTypePolicy.PackAvatarUrl(service, folder, fileName)`,结构:
|
||||
|
||||
```
|
||||
{ApplicationUrl}/FileServer/Backup/{folder}/{ext}/{fileName}
|
||||
```
|
||||
|
||||
`FindByKey` 里对每条配图 `ImageUrl = PackImage(FileName)`;封面 `CoverUrl = PackCover(...)`。作者头像走 `USER_AVATAR` 目录。
|
||||
|
||||
### 6.3 前端直链拼装(`utils/fileUrl.ts` 新增 `backupFileUrl`)
|
||||
|
||||
上传即落盘(与业务数据是否保存无关),故**保存前预览也能取到图**。前端用 `backupFileUrl(folder, name)` 按后端同口径拼直链:
|
||||
|
||||
```
|
||||
backupFileUrl(folder, fileName) -> string
|
||||
裸物理名 -> absoluteFileUrl(`/FileServer/Backup/${folder}/${ext}/${name}`)
|
||||
已是 /FileServer/ 直链、http/blob/data、绝对路径 -> 幂等放行(过 absoluteFileUrl)
|
||||
```
|
||||
|
||||
**历史坑**:曾把裸名直接 `absoluteFileUrl(physicalName)` 拼成坏地址 `{base}/uni-xxx.jpg`(缺 `/FileServer/Backup/{folder}/{ext}/`),既烘进 `Brief` 又导致预览坏。现封面预览、内联插图、缩略图统一走 `backupFileUrl`。地址归一细则见 [file-url.md](./file-url.md)。
|
||||
|
||||
---
|
||||
|
||||
## 7. 前端架构
|
||||
|
||||
| 层 | 文件 | 职责 |
|
||||
|---|---|---|
|
||||
| API(自动生成,**不可手改**) | `service/master/articleService.ts` | `/Article/FindByKey`、`/FindBySearchCriteria`、`/Insert`、`/Update`、`/Delete` |
|
||||
| 数据实体(自动生成) | `data/auto/articleEntity.ts`、`articleFilenameEntity.ts`、`articleFamilyGroupEntity.ts`、`articleData.ts`、`articleResultData.ts` | 与后端字段对齐 |
|
||||
| 组合式 | `composables/useArticles.ts` | 状态与编排:`useArticles()` 返回 `{ article, groups, filenames, loading, errorText, coverUrl, setFlag, ... }`;导出 `YES/NO`、`formatArticleDate`、`plainTextOf`、`fitBriefImages` |
|
||||
| 编辑弹层 | `components/article-editor/article-editor.vue` | `u-popup` + uni `<editor>`:正文富文本、封面上传、配图上传/复用、保存回写 |
|
||||
| 详情页 | `pages/article/article.vue`(路由 `pages/article/article`) | 管理态 / 只读态两副面孔、审核与置顶开关、配图与群组 Tab |
|
||||
| 首页卡片 | `components/family-tree-home/family-tree-home.vue` | 「有家文化传承」区列表卡片(封面、置顶/待审 chip、摘要) |
|
||||
|
||||
`useArticles.ts` 明确「后端已排好序,前端不再筛」——列表顺序以服务端为准。
|
||||
|
||||
---
|
||||
|
||||
## 8. 富文本编辑器要点(`article-editor.vue`)
|
||||
|
||||
### 8.1 跨端上下文获取
|
||||
|
||||
H5 端 `e.detail.editor` 为 `undefined`,须用:
|
||||
|
||||
```
|
||||
uni.createSelectorQuery().in(instance.proxy).select("#id").context(res => res.context).exec()
|
||||
```
|
||||
|
||||
取到 editor 上下文后再 `setContents / insertImage / getContents`。
|
||||
|
||||
### 8.2 插图限宽 `fitBriefImages`
|
||||
|
||||
`rich-text` / `<editor>` 里的 `<img>` 默认按**原图尺寸**渲染,手机屏宽不够就横向撑爆;且 scoped 样式穿不透 `rich-text` 内容,只能改 HTML 本身。`fitBriefImages(html)` 给每个 `<img>` 剥掉 `width/height` 属性与旧 `max-width/height` 内联样式,再烘上:
|
||||
|
||||
```
|
||||
max-width:100%;height:auto;display:block;
|
||||
```
|
||||
|
||||
调用点:**灌编辑器前**(`currentHtml()`)与**保存前**(`onSave` 里 `readHtml()` 之后)各一次,使样式随 `getContents` 回写进 `Brief` 落库,只读态与重开编辑同一效果。详情页 `article.vue` 用 `briefHtml = computed(() => fitBriefImages(...))` 绑 `rich-text`。
|
||||
|
||||
### 8.3 编辑弹层二次打开正文空白的坑(重要)
|
||||
|
||||
`uview-pro` 的 `u-popup` 内容层是 `v-if="visibleSync"`——**关闭即销毁**,重开时内部 `<editor>` 是全新实例;而模块级 `editorCtx`/`editorReady` 跨开关持久,仍指向**已销毁的旧实例**,`setContents` 静默失效 → 第二次打开正文空白。
|
||||
|
||||
修复三件套:
|
||||
- 抽 `currentHtml()` 统一取最新 `Brief`(过 `fitBriefImages`)。
|
||||
- `bindEditorContext`(`@ready` 回调):在 `visible` 且无挂起内容时重取最新 `Brief` 并灌入。
|
||||
- `watch(visible)` 打开分支:对旧 `ctx.setContents` 包 `try/catch`,失败则留给新实例 `@ready` 补灌。
|
||||
- `readHtml` 的 `fail` 分支兜底**当前 `Brief`**(绝不返回空串,否则会把正文洗没)。
|
||||
|
||||
### 8.4 复用已上传配图(`onInsertExisting`)
|
||||
|
||||
配图缩略图可点,把**已上传**的图重新插回正文(不重复上传):
|
||||
|
||||
```
|
||||
onInsertExisting(img):
|
||||
src = absoluteFileUrl(img.ImageUrl) || backupFileUrl(IMAGE_FOLDER, img.FileName)
|
||||
编辑器未就绪 -> toast(article.editorNotReady)
|
||||
否则 editorCtx.insertImage({ src, alt }, () -> toast(article.imageInserted))
|
||||
```
|
||||
|
||||
列表卡片/详情页缩略图 `src` 同口径:`absoluteFileUrl(ImageUrl) || backupFileUrl("ArticleImage", FileName)`。
|
||||
|
||||
---
|
||||
|
||||
## 9. 详情页 UI 口径
|
||||
|
||||
- **两态均不展示封面**:管理态与只读态都移除了封面块;封面只保留在**首页列表卡片**与**编辑弹层上传项**。(产品拍板:详情页不显示封面,其他正常显示。)
|
||||
- **只读态「今日头条」式通栏直排**:不做卡片包裹——`.ar-body` 去白底/圆角/阴影/内边距,标题→作者/日期→分隔线→正文顺排;`.ar-content` 行高 52rpx、色 `#1f2329`。
|
||||
- **管理态**:编号条(编辑/删除图标,编辑图标有效名 `edit-pen`)+ 概览/图片/群组 Tab + 审核/置顶开关。
|
||||
- **右侧边距修复**:`.article-page__scroll`(`scroll-view`)原 `padding: 24rpx` 但盒模型未设 `border-box`,padding 加到 100% 宽**之外**,右侧内边距溢出屏、把日期与正文插图右缘同宽裁掉(左有留白右无、不对称)。修复:
|
||||
|
||||
```css
|
||||
.article-page__scroll {
|
||||
box-sizing: border-box; /* padding 计入宽度内 */
|
||||
overflow-x: hidden; /* 横向溢出兜底 */
|
||||
flex: 1; min-height: 0; padding: 24rpx;
|
||||
}
|
||||
```
|
||||
|
||||
管理态与只读态共用此滚动容器,一改两治。浏览器实测(376px 视口):日期完整、插图左右各约 12px 对称、`scrollWidth == clientWidth` 无横向溢出。
|
||||
|
||||
---
|
||||
|
||||
## 10. 国际化(`locale/lang/zh-CN.json` / `en-US.json`,`article` 段)
|
||||
|
||||
本特性新增关键提示键(节选):
|
||||
|
||||
| 键 | 中文 |
|
||||
|---|---|
|
||||
| `article.imageTapHint` | 点击缩略图可重新插入正文 |
|
||||
| `article.imageInserted` | 已插入正文 |
|
||||
| `article.editorNotReady` | 编辑器未就绪,请稍候再试 |
|
||||
|
||||
其余如 `article.unnamed`、`article.fieldTop`、`article.tagTop`、`article.tagPending` 等沿用既有命名。
|
||||
|
||||
---
|
||||
|
||||
## 11. 已知边界与待办
|
||||
|
||||
1. **`order_by` 无 App 内编辑入口**:见 §5,排序组合的「排序」级暂不生效。
|
||||
2. **详情页直达路由**:直接访问 `http://localhost:5173/#/pages/article/article?name=...` 会渲染成首页 dashboard,须从首页文章卡点进去才走短地址 `/article?name=...`。独立于本特性,待单独跟进。
|
||||
3. **后端排序/可见性改动需重启**:`ArticleService.auto.cs` 是服务代码逻辑,非权限数据,改后须重新生成并重启 `CloudBuilder.Web.Entry`(重登无效)。
|
||||
|
||||
---
|
||||
|
||||
## 12. 验证方法(自动化优先)
|
||||
|
||||
- **前端类型裁决**:`node D:\Docker\uniapp\youjiala\node_modules\vue-tsc\bin\vue-tsc.js --noEmit`,看退出码 0(eslint 风格噪声一贯忽略)。
|
||||
- **后端编译**:`dotnet build CloudBuilder.Master.Service\...csproj`,确认 0 error(既有 `DmsFileService` nullable 警告无关)。
|
||||
- **连库核对**:`node` 绝对路径 + `mysql2`(`nas.youjiala.com:3307/Furion`,utf8mb4),核对 `article` / `article_family_group` / `article_filename` / `family_group_manager` 数据链与排序。PowerShell 内嵌引号会毁 `node -e`,须写临时 `.js`(放已 gitignore 的 `.dbtmp/`)执行。
|
||||
- **视觉核验**:浏览器子代理设手机竖屏(约 376px)截图,量日期/插图右缘留白与 `scrollWidth vs clientWidth`。
|
||||
@@ -155,6 +155,10 @@
|
||||
// 共享大厅(短地址 /share-hall):文件页快捷入口跳入,家人共享目录浏览与组内建目录
|
||||
{
|
||||
"path": "pages/share-hall/share-hall"
|
||||
},
|
||||
// 有家文化传承文章详情(短地址 /article):首页文章卡跳入,只读/管理两态 + 底部富文本编辑
|
||||
{
|
||||
"path": "pages/article/article"
|
||||
}
|
||||
],
|
||||
// 全局配置
|
||||
|
||||
@@ -0,0 +1,504 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 有家文化传承「文章」详情页(短地址 /article):图2 只读态与图3 管理态同页两态。
|
||||
* 入参经 query:name=<ArticleName> 打开某篇文章;mode=create 直接进新建态并弹底部编辑层。
|
||||
* 数据链全在 composables/useArticles,本页只渲染 + 把点击转成调用;编辑复用 components/article-editor。
|
||||
* 角色分权(后端 FindByKey 下发 IsOwner/IsManager/IsMember,前端只负责显隐):
|
||||
* - 只读态(普通成员,图2):封面 + 标题 + 富文本正文;未审核非作者后端已拦,进不来这里;
|
||||
* - 管理态(作者/群管理员,图3):顶部封面卡(编号 + 编辑/删除)+ 概览/图片/群组三段 Tab,
|
||||
* 概览含「已审核 / 置顶」行内即时开关(改后立即 Update 落库)。
|
||||
*/
|
||||
import { onLoad } from "@dcloudio/uni-app";
|
||||
import { useLocale } from "uview-pro";
|
||||
import { computed, ref } from "vue";
|
||||
import { briefForDisplay, useArticles, useThemeGradient, useThemeTokens } from "@/composables";
|
||||
import { smartBack } from "@/router/routes";
|
||||
import { absoluteFileUrl, backupFileUrl } from "@/utils/fileUrl";
|
||||
|
||||
const { t } = useLocale();
|
||||
const { themeBackground } = useThemeGradient();
|
||||
const { primary } = useThemeTokens();
|
||||
|
||||
const api = useArticles();
|
||||
const { article, groups, filenames, loading, errorText, coverUrl } = api;
|
||||
|
||||
/** 底部编辑弹层可见(新建/编辑共用) */
|
||||
const editorVisible = ref(false);
|
||||
/** 管理态三段 Tab:概览 / 图片 / 群组 */
|
||||
const activeTab = ref<"overview" | "images" | "groups">("overview");
|
||||
|
||||
/** 管理态:作者或任一关联群组管理员(图3);否则只读态(图2) */
|
||||
const manageMode = computed(() => !!article.value && api.canManage.value);
|
||||
|
||||
async function openByQuery(name: string): Promise<void> {
|
||||
await api.openArticle(name);
|
||||
if (errorText.value) {
|
||||
uni.showToast({ title: errorText.value, icon: "none" });
|
||||
}
|
||||
}
|
||||
|
||||
onLoad(async (query) => {
|
||||
const mode = String(query?.mode || "");
|
||||
const name = decodeURIComponent(String(query?.name || ""));
|
||||
if (mode === "create") {
|
||||
api.startCreate();
|
||||
editorVisible.value = true;
|
||||
return;
|
||||
}
|
||||
if (name) {
|
||||
await openByQuery(name);
|
||||
}
|
||||
});
|
||||
|
||||
function onBack(): void {
|
||||
smartBack();
|
||||
}
|
||||
|
||||
function onEdit(): void {
|
||||
api.startEdit();
|
||||
editorVisible.value = true;
|
||||
}
|
||||
|
||||
function onCloseEditor(): void {
|
||||
editorVisible.value = false;
|
||||
// 新建态取消:无落库直接退出;编辑态取消:丢弃本地改动重拉详情
|
||||
if (api.creating.value) {
|
||||
onBack();
|
||||
return;
|
||||
}
|
||||
void api.cancelEdit();
|
||||
}
|
||||
|
||||
async function onEditorSaved(): Promise<void> {
|
||||
editorVisible.value = false;
|
||||
if (article.value?.ArticleName) {
|
||||
await api.openArticle(article.value.ArticleName);
|
||||
}
|
||||
}
|
||||
|
||||
function onApproveToggle(on: boolean): void {
|
||||
void runFlag("ApprovedIndc", on);
|
||||
}
|
||||
|
||||
function onTopToggle(on: boolean): void {
|
||||
void runFlag("TopIndc", on);
|
||||
}
|
||||
|
||||
async function runFlag(field: "ApprovedIndc" | "TopIndc", on: boolean): Promise<void> {
|
||||
try {
|
||||
await api.setFlag(field, on);
|
||||
}
|
||||
catch (error: any) {
|
||||
uni.showToast({ title: error?.message || t("FilePage.actionFailed"), icon: "none" });
|
||||
// 失败回滚本地:重拉详情还原开关
|
||||
if (article.value?.ArticleName) {
|
||||
await api.openArticle(article.value.ArticleName);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function onDelete(): void {
|
||||
uni.showModal({
|
||||
title: t("article.deleteTitle"),
|
||||
content: t("article.deleteConfirm"),
|
||||
confirmColor: primary.value,
|
||||
success: async (res) => {
|
||||
if (!res.confirm) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await api.remove();
|
||||
onBack();
|
||||
}
|
||||
catch (error: any) {
|
||||
uni.showToast({ title: error?.message || t("FilePage.actionFailed"), icon: "none" });
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function imageSrc(row: { ImageUrl: string; FileName: string }): string {
|
||||
// 已保存态后端下发打包好的 ImageUrl;极端缺失时按落盘口径拼直链,不拼裸物理名坏地址
|
||||
return absoluteFileUrl(row.ImageUrl) || backupFileUrl("ArticleImage", row.FileName);
|
||||
}
|
||||
|
||||
/** 只读态正文:给插图烘上限宽内联样式(scoped 穿透不了 rich-text,只能改 HTML)并把内联 img src 归一到当前端可达地址(修 App 上正文插图不显示) */
|
||||
const briefHtml = computed(() => briefForDisplay(article.value?.Brief));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<app-page :hide-nav="true" :show-tabbar="false">
|
||||
<view class="article-page" :style="{ background: themeBackground }">
|
||||
<view class="article-page__head">
|
||||
<hero-navbar :title="t('article.title')" @back="onBack" />
|
||||
</view>
|
||||
|
||||
<view v-if="loading" class="article-page__state">
|
||||
<text class="article-page__state-text">{{ t("FilePage.loading") }}</text>
|
||||
</view>
|
||||
<view v-else-if="errorText && !article" class="article-page__state">
|
||||
<text class="article-page__state-text article-page__state-text--error">{{ errorText }}</text>
|
||||
</view>
|
||||
|
||||
<scroll-view v-else-if="article" scroll-y class="article-page__scroll">
|
||||
<!-- ===== 管理态头卡(图3):顶部封面 + 下方编号行(编号 + 编辑/删除)===== -->
|
||||
<template v-if="manageMode">
|
||||
<view class="ac-hero">
|
||||
<image v-if="coverUrl" class="ac-hero__cover" :src="coverUrl" mode="aspectFill" />
|
||||
<view class="ac-hero__bar">
|
||||
<view class="ac-hero__code">
|
||||
<text class="ac-hero__code-text">{{ article.ArticleName }}</text>
|
||||
</view>
|
||||
<view class="ac-hero__acts">
|
||||
<view class="ac-hero__act" @tap="onEdit">
|
||||
<u-icon name="edit-pen" size="30" :color="primary" />
|
||||
</view>
|
||||
<view class="ac-hero__act ac-hero__act--del" @tap="onDelete">
|
||||
<u-icon name="trash" size="30" color="#f0413e" />
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 三段 Tab -->
|
||||
<view class="ac-tabs">
|
||||
<view class="ac-tab" :class="{ 'ac-tab--on': activeTab === 'overview' }"
|
||||
@tap="activeTab = 'overview'">
|
||||
<text>{{ t("article.tabOverview") }}</text>
|
||||
</view>
|
||||
<view class="ac-tab" :class="{ 'ac-tab--on': activeTab === 'images' }"
|
||||
@tap="activeTab = 'images'">
|
||||
<text>{{ t("article.tabImages") }}</text>
|
||||
<text class="ac-tab__cnt">{{ filenames.length }}</text>
|
||||
</view>
|
||||
<view class="ac-tab" :class="{ 'ac-tab--on': activeTab === 'groups' }"
|
||||
@tap="activeTab = 'groups'">
|
||||
<text>{{ t("article.tabGroups") }}</text>
|
||||
<text class="ac-tab__cnt">{{ groups.length }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 概览:标题 + 正文 + 已审核/置顶即时开关 -->
|
||||
<view v-show="activeTab === 'overview'" class="ac-card">
|
||||
<view class="ac-field ac-field--row">
|
||||
<text class="ac-field__label">{{ t("article.fieldApproved") }}</text>
|
||||
<u-switch :model-value="article.ApprovedIndc === 'Y'" :active-color="primary"
|
||||
@change="(v: any) => onApproveToggle(!!v)" />
|
||||
</view>
|
||||
<view class="ac-field ac-field--row">
|
||||
<text class="ac-field__label">{{ t("article.fieldTop") }}</text>
|
||||
<u-switch :model-value="article.TopIndc === 'Y'" :active-color="primary"
|
||||
@change="(v: any) => onTopToggle(!!v)" />
|
||||
</view>
|
||||
<view class="ac-divider" />
|
||||
<text class="ac-title">{{ article.ArticleTitle || t("article.unnamed") }}</text>
|
||||
<rich-text class="ac-body" :nodes="briefHtml" />
|
||||
</view>
|
||||
|
||||
<!-- 图片:正文配图明细 -->
|
||||
<view v-show="activeTab === 'images'" class="ac-card">
|
||||
<view v-if="!filenames.length" class="ac-empty"><text>{{ t("article.noImage") }}</text></view>
|
||||
<view v-else class="ac-images">
|
||||
<image v-for="img in filenames" :key="img.FileName" class="ac-images__item"
|
||||
:src="imageSrc(img)" mode="aspectFill" />
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 群组:关联族谱 -->
|
||||
<view v-show="activeTab === 'groups'" class="ac-card">
|
||||
<view v-if="!groups.length" class="ac-empty"><text>{{ t("article.noGroup") }}</text></view>
|
||||
<view v-for="g in groups" :key="g.FamilyName" class="ac-group">
|
||||
<text class="ac-group__name">{{ g.FamilyLocalName || g.FamilyName }}</text>
|
||||
<text class="ac-group__code">{{ g.FamilyName }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<!-- ===== 只读态(图2):普通成员视图,顶部封面 + 标题 + 作者/日期 + 正文 ===== -->
|
||||
<template v-else>
|
||||
<view class="ar-body">
|
||||
<image v-if="coverUrl" class="ar-cover" :src="coverUrl" mode="aspectFill" />
|
||||
<text class="ar-title">{{ article.ArticleTitle || t("article.unnamed") }}</text>
|
||||
<view class="ar-meta">
|
||||
<image v-if="article.AuthorAvatarUrl" class="ar-meta__avatar"
|
||||
:src="absoluteFileUrl(article.AuthorAvatarUrl)" mode="aspectFill" />
|
||||
<text class="ar-meta__author">{{ article.AuthorLocalName || t("article.unknownAuthor")
|
||||
}}</text>
|
||||
<text class="ar-meta__date">{{ api.formatDate(article.CreatedDatetime) }}</text>
|
||||
</view>
|
||||
<view class="ar-divider" />
|
||||
<rich-text class="ar-content" :nodes="briefHtml" />
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<view class="article-page__space" />
|
||||
</scroll-view>
|
||||
|
||||
<!-- 底部编辑弹层(新建 / 编辑复用) -->
|
||||
<article-editor :visible="editorVisible" :api="api" @close="onCloseEditor" @saved="onEditorSaved" />
|
||||
</view>
|
||||
</app-page>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.article-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.article-page__head {
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.article-page__scroll {
|
||||
/* box-sizing 必须 border-box:否则 padding 24rpx 会加到 100% 宽之外,
|
||||
右侧内边距溢出屏外的,把日期、正文插图右缘同宽裁掉(管理态/只读态同一滚动容器都受影响) */
|
||||
box-sizing: border-box;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
padding: 24rpx;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.article-page__state {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 120rpx 32rpx;
|
||||
}
|
||||
|
||||
.article-page__state-text {
|
||||
font-size: 26rpx;
|
||||
color: #9aa0a6;
|
||||
}
|
||||
|
||||
.article-page__state-text--error {
|
||||
color: #f0413e;
|
||||
}
|
||||
|
||||
.article-page__space {
|
||||
height: 48rpx;
|
||||
}
|
||||
|
||||
/* ===== 管理态头卡 ===== */
|
||||
.ac-hero {
|
||||
overflow: hidden;
|
||||
border-radius: 28rpx;
|
||||
background-color: #ffffff;
|
||||
box-shadow: 0 8rpx 24rpx rgba(31, 35, 41, 0.06);
|
||||
}
|
||||
|
||||
.ac-hero__bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 20rpx 28rpx;
|
||||
}
|
||||
|
||||
/* 管理态头卡封面:整宽嵌在卡内,ac-hero 已 overflow:hidden + 圆角,图自动被裁成下圆角卡面 */
|
||||
.ac-hero__cover {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 320rpx;
|
||||
background-color: #f2f3f5;
|
||||
}
|
||||
|
||||
.ac-hero__code-text {
|
||||
font-size: 24rpx;
|
||||
color: #8f959e;
|
||||
}
|
||||
|
||||
.ac-hero__acts {
|
||||
display: flex;
|
||||
gap: 20rpx;
|
||||
}
|
||||
|
||||
.ac-hero__act {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 64rpx;
|
||||
height: 64rpx;
|
||||
border-radius: 50%;
|
||||
background-color: var(--fg-soft, #eaf4ff);
|
||||
}
|
||||
|
||||
.ac-hero__act--del {
|
||||
background-color: #fdeceb;
|
||||
}
|
||||
|
||||
/* ===== 三段 Tab ===== */
|
||||
.ac-tabs {
|
||||
display: flex;
|
||||
gap: 16rpx;
|
||||
margin: 24rpx 0;
|
||||
}
|
||||
|
||||
.ac-tab {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8rpx;
|
||||
padding: 14rpx 28rpx;
|
||||
font-size: 26rpx;
|
||||
color: #5c626b;
|
||||
background-color: rgba(255, 255, 255, 0.7);
|
||||
border-radius: 999rpx;
|
||||
}
|
||||
|
||||
.ac-tab--on {
|
||||
color: #ffffff;
|
||||
background: var(--fg-gradient, #ff9d45);
|
||||
}
|
||||
|
||||
.ac-tab__cnt {
|
||||
font-size: 22rpx;
|
||||
}
|
||||
|
||||
.ac-card {
|
||||
padding: 28rpx;
|
||||
border-radius: 28rpx;
|
||||
background-color: #ffffff;
|
||||
box-shadow: 0 8rpx 24rpx rgba(31, 35, 41, 0.06);
|
||||
}
|
||||
|
||||
.ac-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10rpx;
|
||||
padding: 12rpx 0;
|
||||
}
|
||||
|
||||
.ac-field--row {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.ac-field__label {
|
||||
font-size: 28rpx;
|
||||
color: #1f2329;
|
||||
}
|
||||
|
||||
.ac-divider {
|
||||
height: 1rpx;
|
||||
margin: 20rpx 0;
|
||||
background-color: #eceef1;
|
||||
}
|
||||
|
||||
.ac-title {
|
||||
display: block;
|
||||
margin: 8rpx 0 20rpx;
|
||||
font-size: 40rpx;
|
||||
font-weight: 700;
|
||||
color: #1f2329;
|
||||
}
|
||||
|
||||
.ac-body {
|
||||
font-size: 28rpx;
|
||||
line-height: 46rpx;
|
||||
color: #3d4149;
|
||||
}
|
||||
|
||||
.ac-empty {
|
||||
padding: 60rpx 0;
|
||||
font-size: 26rpx;
|
||||
color: #9aa0a6;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.ac-images {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 16rpx;
|
||||
}
|
||||
|
||||
.ac-images__item {
|
||||
width: 200rpx;
|
||||
height: 200rpx;
|
||||
border-radius: 16rpx;
|
||||
}
|
||||
|
||||
.ac-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 20rpx 0;
|
||||
border-bottom: 1rpx solid #f0f1f3;
|
||||
}
|
||||
|
||||
.ac-group:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.ac-group__name {
|
||||
font-size: 28rpx;
|
||||
color: #1f2329;
|
||||
}
|
||||
|
||||
.ac-group__code {
|
||||
font-size: 22rpx;
|
||||
color: #bfc3c9;
|
||||
}
|
||||
|
||||
/* ===== 只读态(今日头条式通栏直排:无卡片包裹,标题/作者/正文直接顺排)===== */
|
||||
.ar-body {
|
||||
padding: 8rpx 0 0;
|
||||
}
|
||||
|
||||
/* 只读态顶部封面:自带圆角与背景占位,与正文同宽(受 scroll 24rpx 内边距约束) */
|
||||
.ar-cover {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 360rpx;
|
||||
margin-bottom: 24rpx;
|
||||
border-radius: 20rpx;
|
||||
background-color: #f2f3f5;
|
||||
}
|
||||
|
||||
.ar-title {
|
||||
display: block;
|
||||
font-size: 42rpx;
|
||||
font-weight: 700;
|
||||
line-height: 60rpx;
|
||||
color: #1f2329;
|
||||
}
|
||||
|
||||
.ar-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12rpx;
|
||||
margin-top: 20rpx;
|
||||
}
|
||||
|
||||
.ar-meta__avatar {
|
||||
width: 44rpx;
|
||||
height: 44rpx;
|
||||
border-radius: 50%;
|
||||
background-color: #f2f3f5;
|
||||
}
|
||||
|
||||
.ar-meta__author {
|
||||
font-size: 24rpx;
|
||||
color: #5c626b;
|
||||
}
|
||||
|
||||
.ar-meta__date {
|
||||
margin-left: auto;
|
||||
font-size: 22rpx;
|
||||
color: #bfc3c9;
|
||||
}
|
||||
|
||||
.ar-divider {
|
||||
height: 1rpx;
|
||||
margin: 24rpx 0;
|
||||
background-color: #eceef1;
|
||||
}
|
||||
|
||||
.ar-content {
|
||||
font-size: 30rpx;
|
||||
line-height: 52rpx;
|
||||
color: #1f2329;
|
||||
}
|
||||
</style>
|
||||
@@ -78,6 +78,8 @@ export const PAGE_PATHS = {
|
||||
shareHall: "pages/share-hall/share-hall",
|
||||
// 我的主题(短地址 /theme):「我的」页跳入,主题配色与深色模式设置
|
||||
themePage: "pages/theme/theme",
|
||||
// 有家文化传承文章详情(短地址 /article):首页文章卡「+」/点条目跳入,只读/管理两态
|
||||
articlePage: "pages/article/article",
|
||||
} as const;
|
||||
|
||||
export type PageName = keyof typeof PAGE_PATHS;
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
|
||||
import { request } from "@/utils/request";
|
||||
import type { ArticleData } from "@/data/auto/articleData";
|
||||
import type { ArticleSearchCriteria } from "@/data/auto/articleSearchCriteria";
|
||||
import type { ArticleResultData } from "@/data/auto/articleResultData";
|
||||
|
||||
class ArticleService{
|
||||
|
||||
async FindByKey(data: string) {
|
||||
return request<ArticleData>(
|
||||
{
|
||||
url: "/Article/FindByKey?key="+data,
|
||||
method: "post",
|
||||
data,
|
||||
}
|
||||
)
|
||||
};
|
||||
|
||||
async FindBySearchCriteria(data: ArticleSearchCriteria) {
|
||||
return request<ArticleResultData>(
|
||||
{
|
||||
url: "/Article/FindBySearchCriteria",
|
||||
method: "post",
|
||||
data,
|
||||
}
|
||||
)
|
||||
};
|
||||
|
||||
async Insert(data: ArticleData) {
|
||||
return request<ArticleData>(
|
||||
{
|
||||
url: "/Article/Insert",
|
||||
method: "post",
|
||||
data,
|
||||
}
|
||||
)
|
||||
};
|
||||
|
||||
async Update(data: ArticleData) {
|
||||
return request<ArticleData>(
|
||||
{
|
||||
url: "/Article/Update",
|
||||
method: "post",
|
||||
data,
|
||||
}
|
||||
)
|
||||
};
|
||||
|
||||
async Delete(data: string) {
|
||||
return request<any>(
|
||||
{
|
||||
url: "/Article/Delete?key="+data,
|
||||
method: "post",
|
||||
data,
|
||||
}
|
||||
)
|
||||
};
|
||||
|
||||
}
|
||||
|
||||
const articleService= new ArticleService();
|
||||
export default articleService;
|
||||
@@ -146,6 +146,67 @@ export function wanFileUrl(path?: string | null): string {
|
||||
* 提交用的相对路径 /FileServer/... 与裸物理名(无主机)、blob:/data:/本地路径均不含“主机+/FileServer”,天然被排除 */
|
||||
const FILE_URL_PATH_MARKER = "/FileServer/";
|
||||
|
||||
/**
|
||||
* 上传即落盘的备份文件直链:/FileServer/Backup/{folder}/{ext}/{物理名}。
|
||||
*
|
||||
* 为什么需要:DmsFile/UploadFile 上传成功后文件立刻落盘(与是否保存业务数据无关),
|
||||
* 后端读取态才用 PackAvatarUrl 把物理名打成这个结构;前端「保存前预览 / 富文本内联插图」
|
||||
* 拿不到后端打包地址,直接 absoluteFileUrl(裸物理名) 会拼出 {base}/uni-xxx.jpg 的坏地址。
|
||||
* 这里按后端同口径自行拼相对路径再过 absoluteFileUrl(H5 锁 origin、App 跟线路),
|
||||
* 保存前后同一个地址都能取到图。folder 须与后端 UploadFolderPolicy 一致(如 ArticleCover / ArticleImage)。
|
||||
*/
|
||||
export function backupFileUrl(
|
||||
folder: string,
|
||||
fileName?: string | null,
|
||||
): string {
|
||||
const name = String(fileName ?? "").trim();
|
||||
if (!name) {
|
||||
return "";
|
||||
}
|
||||
// 已是绝对/相对直链或本地资源:交给 absoluteFileUrl 归一,不再重复打包
|
||||
if (
|
||||
name.includes(FILE_URL_PATH_MARKER) ||
|
||||
/^(?:https?:|blob:|data:|\/)/i.test(name)
|
||||
) {
|
||||
return absoluteFileUrl(name);
|
||||
}
|
||||
const dot = name.lastIndexOf(".");
|
||||
const ext = dot > 0 ? name.slice(dot + 1).toLowerCase() : "ext";
|
||||
return absoluteFileUrl(`/FileServer/Backup/${folder}/${ext}/${name}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 改写富文本(文章正文 Brief)HTML 里每个 <img> 的 src。
|
||||
*
|
||||
* 为什么单独处理:响应层 normalizePayloadFileUrls 只改「整个字段值就是绝对 FileServer URL」的串,
|
||||
* 而 Brief 是 HTML(以 <p> 开头),absoluteHost 判非绝对 → 漏改。后端 Debug 会把 localhost:5235
|
||||
* 打进正文内联 <img src>,App 上 localhost 指向手机自己,插图必挂;封面/配图走 absoluteFileUrl 才没事。
|
||||
* - display:把 src 归一到当前端可达的绝对地址(absoluteFileUrl,App 跟线路、H5 锁 origin);
|
||||
* - store:把 src 剥成 host 无关的相对路径 /FileServer/...(toPathAndQuery),存库后跨环境可移植。
|
||||
* 判据极窄:只碰含 /FileServer/ 的地址;blob:/data:/file:/设备路径等本地资源、外部图 URL 一律原样放行。
|
||||
*/
|
||||
export function mapBriefImageSrc(
|
||||
html: string,
|
||||
mode: "display" | "store",
|
||||
): string {
|
||||
return String(html ?? "").replace(
|
||||
/(<img\b[^>]*?\ssrc=)(["'])([^"']*)\2/gi,
|
||||
(whole, prefix: string, quote: string, src: string) => {
|
||||
const trimmed = String(src).trim();
|
||||
if (
|
||||
!trimmed ||
|
||||
isLocalResource(trimmed) ||
|
||||
!trimmed.includes(FILE_URL_PATH_MARKER)
|
||||
) {
|
||||
return whole;
|
||||
}
|
||||
const next =
|
||||
mode === "display" ? absoluteFileUrl(trimmed) : toPathAndQuery(trimmed);
|
||||
return next ? `${prefix}${quote}${next}${quote}` : whole;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/** 取绝对 http(s) 地址的主机(小写、含端口);非绝对地址返回 null */
|
||||
function absoluteHost(url: string): string | null {
|
||||
const matched = /^https?:\/\/([^/]+)/i.exec(url);
|
||||
|
||||
Reference in new issue
Block a user