add StoreDiary
This commit is contained in:
@@ -0,0 +1,268 @@
|
||||
<template>
|
||||
<div v-can="permissionCodes.storeDiary.read" class="store-diary-detail">
|
||||
<template v-if="article">
|
||||
<header class="header" v-entrance-transition>
|
||||
<h1 v-text="article.title || '未命名日记'" />
|
||||
<div v-if="article.description" class="description" v-text="article.description" />
|
||||
<pass-time-label
|
||||
class="time gray"
|
||||
v-if="article.createdAt"
|
||||
:timestamp="article.createdAt"
|
||||
underline
|
||||
/>
|
||||
</header>
|
||||
<t-divider />
|
||||
<section class="content" v-entrance-transition="70">
|
||||
<article-view
|
||||
:article="article"
|
||||
empty-text="暂无日记正文"
|
||||
:resolve-attachment-url="resolveAttachmentUrl"
|
||||
/>
|
||||
</section>
|
||||
<footer class="footer gray">
|
||||
<div class="updater">
|
||||
<div>最近更新人:</div>
|
||||
<div v-if="article.updater" class="updater">
|
||||
<t-image
|
||||
class="avatar"
|
||||
:src="getUpdaterAvatar(article.updater)"
|
||||
:alt="getUpdaterName(article.updater)"
|
||||
fit="cover"
|
||||
/>
|
||||
<span v-text="getUpdaterName(article.updater)" />
|
||||
</div>
|
||||
<span v-else v-text="'未知用户'" />
|
||||
</div>
|
||||
<div class="updated-at">
|
||||
<span>最近更新时间:</span>
|
||||
<pass-time-label v-if="article.updatedAt" :timestamp="article.updatedAt" />
|
||||
<span v-else v-text="'未知时间'" />
|
||||
</div>
|
||||
</footer>
|
||||
<t-button
|
||||
v-if="canDelete"
|
||||
class="delete cell-button"
|
||||
theme="danger"
|
||||
block
|
||||
:loading="isDeleting"
|
||||
@click="confirmDelete"
|
||||
>
|
||||
删除日记
|
||||
</t-button>
|
||||
</template>
|
||||
<t-empty v-else :description="isLoading ? '正在加载门店日记' : '门店日记不存在'" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ArticleView } from "timi-tdesign-mobile";
|
||||
import { DialogPlugin } from "tdesign-mobile-vue";
|
||||
import { PassTimeLabel } from "timi-web";
|
||||
import { UserAPI, useUserStore } from "timi-web-api";
|
||||
import StoreDiaryAPI from "@/api/StoreDiaryAPI";
|
||||
import { useCurrentStoreStore } from "@/store/currentStoreStore";
|
||||
import { useNavBarStore } from "@/store/navBarStore";
|
||||
import type { StoreDiary } from "@/types/StoreDiary";
|
||||
import { getAttachmentReadURL } from "@/utils/attachment";
|
||||
import { buildFallbackAvatar } from "@/utils/avatar";
|
||||
import { getErrorMessage } from "@/utils/error";
|
||||
import { Message } from "@/utils/message";
|
||||
import { permissionCodes } from "@/utils/permission";
|
||||
|
||||
defineOptions({
|
||||
name: "StoreDiaryDetail"
|
||||
});
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const navBarStore = useNavBarStore();
|
||||
const userStore = useUserStore();
|
||||
const currentStoreStore = useCurrentStoreStore();
|
||||
const canUpdate = computed(() => userStore.hasPermissions(permissionCodes.storeDiary.update));
|
||||
const canDelete = computed(() => userStore.hasPermissions(permissionCodes.storeDiary.delete));
|
||||
|
||||
// ---------- 日记数据 ----------
|
||||
|
||||
const article = ref<StoreDiary>();
|
||||
const isLoading = ref(false);
|
||||
const isDeleting = ref(false);
|
||||
let loadToken = 0;
|
||||
|
||||
function resolveAttachmentUrl(attachmentId: string): string {
|
||||
return getAttachmentReadURL(attachmentId);
|
||||
}
|
||||
|
||||
function getUpdaterName(item: NonNullable<StoreDiary["updater"]>): string {
|
||||
return item.nick?.trim() || item.name?.trim() || "未知用户";
|
||||
}
|
||||
|
||||
function getUpdaterAvatar(item: NonNullable<StoreDiary["updater"]>): string {
|
||||
return UserAPI.getAvatarURL(item) || buildFallbackAvatar(getUpdaterName(item));
|
||||
}
|
||||
|
||||
async function prepareStoreScope(): Promise<void> {
|
||||
await currentStoreStore.loadLoginStore();
|
||||
}
|
||||
|
||||
function setRightAction(): void {
|
||||
if (article.value?.id && canUpdate.value) {
|
||||
navBarStore.setRightText("编辑", "var(--td-brand-color)", toEdit);
|
||||
return;
|
||||
}
|
||||
navBarStore.clearRight();
|
||||
}
|
||||
|
||||
async function loadArticle(): Promise<void> {
|
||||
const requestToken = loadToken + 1;
|
||||
loadToken = requestToken;
|
||||
article.value = undefined;
|
||||
navBarStore.clearRight();
|
||||
const id = String(route.params.id || "");
|
||||
if (!id) {
|
||||
return;
|
||||
}
|
||||
|
||||
isLoading.value = true;
|
||||
try {
|
||||
await prepareStoreScope();
|
||||
const result = await StoreDiaryAPI.detail(id);
|
||||
if (requestToken !== loadToken) {
|
||||
return;
|
||||
}
|
||||
article.value = result;
|
||||
setRightAction();
|
||||
} catch (error) {
|
||||
Message.error(getErrorMessage(error, "门店日记加载失败"));
|
||||
} finally {
|
||||
if (requestToken === loadToken) {
|
||||
isLoading.value = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 操作 ----------
|
||||
|
||||
async function toEdit(): Promise<void> {
|
||||
if (!article.value?.id || !canUpdate.value) {
|
||||
return;
|
||||
}
|
||||
await router.push({
|
||||
name: "StoreDiaryEdit",
|
||||
params: { id: article.value.id }
|
||||
});
|
||||
}
|
||||
|
||||
async function confirmDelete(): Promise<void> {
|
||||
if (!article.value?.id || !canDelete.value || isDeleting.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const diaryId = article.value.id;
|
||||
const dialog = DialogPlugin.confirm({
|
||||
title: "删除门店日记",
|
||||
content: `确定删除“${article.value.title || "未命名日记"}”吗?删除后无法恢复。`,
|
||||
cancelBtn: "取消",
|
||||
confirmBtn: "删除",
|
||||
onCancel() {
|
||||
dialog.destroy();
|
||||
},
|
||||
async onConfirm() {
|
||||
dialog.destroy();
|
||||
isDeleting.value = true;
|
||||
navBarStore.clearRight();
|
||||
try {
|
||||
await StoreDiaryAPI.remove(diaryId);
|
||||
Message.success("门店日记已删除");
|
||||
await router.replace({ name: "StoreDiaryList" });
|
||||
} catch (error) {
|
||||
Message.error(getErrorMessage(error, "门店日记删除失败"));
|
||||
setRightAction();
|
||||
} finally {
|
||||
isDeleting.value = false;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
onMounted(loadArticle);
|
||||
|
||||
onUnmounted(() => navBarStore.clearRight());
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.store-diary-detail {
|
||||
padding: var(--app-nav-height) 0 calc(1rem + env(safe-area-inset-bottom, 0));
|
||||
|
||||
.header {
|
||||
padding: 1.25rem 1rem;
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: 1.35rem;
|
||||
text-align: center;
|
||||
line-height: 1.5;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.time {
|
||||
display: block;
|
||||
margin-top: 1rem;
|
||||
font-size: .875rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.description {
|
||||
color: var(--td-text-color-secondary);
|
||||
font-size: .875rem;
|
||||
margin-top: .75rem;
|
||||
word-break: break-word;
|
||||
line-height: 1.6;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: 1rem;
|
||||
|
||||
:deep(.content) {
|
||||
border: none;
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.footer {
|
||||
padding: 0 1rem;
|
||||
font-size: .875rem;
|
||||
|
||||
.updater {
|
||||
gap: .5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
.avatar {
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
border-radius: 50%;
|
||||
}
|
||||
}
|
||||
|
||||
.updated-at {
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
.delete {
|
||||
margin-top: 3rem;
|
||||
}
|
||||
}
|
||||
|
||||
:global(html.theme-dark) {
|
||||
.store-diary-detail {
|
||||
.meta,
|
||||
.description {
|
||||
color: #94a3b8;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,322 @@
|
||||
<template>
|
||||
<div v-can="pageAccess" class="store-diary-edit">
|
||||
<t-form class="form" label-align="left" label-width="5rem" v-entrance-transition>
|
||||
<t-form-item label="所属门店">
|
||||
<div class="picker-text" v-text="selectedStoreName" />
|
||||
</t-form-item>
|
||||
<t-form-item label="日记标题" required-mark>
|
||||
<t-input
|
||||
v-model.trim="article.title"
|
||||
borderless
|
||||
maxlength="100"
|
||||
placeholder="请输入日记标题"
|
||||
/>
|
||||
</t-form-item>
|
||||
<t-form-item label="日记摘要">
|
||||
<t-textarea
|
||||
v-model.trim="article.description"
|
||||
borderless
|
||||
maxlength="200"
|
||||
autosize
|
||||
placeholder="可填写本篇日记摘要"
|
||||
/>
|
||||
</t-form-item>
|
||||
<section class="editor-wrapper" v-entrance-transition="70">
|
||||
<article-editor
|
||||
ref="articleEditorRef"
|
||||
:model-value="article"
|
||||
class="editor"
|
||||
placeholder="请输入门店日记正文,可插入图片或视频"
|
||||
:upload-image="uploadImage"
|
||||
:upload-video="uploadVideo"
|
||||
:resolve-attachment-url="resolveAttachmentUrl"
|
||||
@update:model-value="updateArticle"
|
||||
/>
|
||||
</section>
|
||||
</t-form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
ArticleEditor,
|
||||
type Article as ArticleEditorArticle,
|
||||
type ArticleEditorExpose,
|
||||
type UploadResult
|
||||
} from "timi-tdesign-mobile";
|
||||
import { AttachmentAPI, useUserStore } from "timi-web-api";
|
||||
import StoreDiaryAPI from "@/api/StoreDiaryAPI";
|
||||
import { useCurrentStoreStore } from "@/store/currentStoreStore";
|
||||
import { useNavBarStore } from "@/store/navBarStore";
|
||||
import type { StoreDiary } from "@/types/StoreDiary";
|
||||
import { getErrorMessage } from "@/utils/error";
|
||||
import { compressUploadImage } from "@/utils/imageCompression";
|
||||
import { getAttachmentReadURL } from "@/utils/attachment";
|
||||
import { Message } from "@/utils/message";
|
||||
import { permissionCodes } from "@/utils/permission";
|
||||
|
||||
defineOptions({
|
||||
name: "StoreDiaryEdit"
|
||||
});
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const navBarStore = useNavBarStore();
|
||||
const userStore = useUserStore();
|
||||
const currentStoreStore = useCurrentStoreStore();
|
||||
const routeId = computed(() => String(route.params.id || ""));
|
||||
const isCreateMode = computed(() => !routeId.value);
|
||||
const savePermissionCode = computed(() => isCreateMode.value ? permissionCodes.storeDiary.create : permissionCodes.storeDiary.update);
|
||||
const pageAccess = computed(() => ({
|
||||
value: [permissionCodes.storeDiary.read, savePermissionCode.value],
|
||||
mode: "and" as const
|
||||
}));
|
||||
|
||||
// ---------- 日记表单 ----------
|
||||
|
||||
const isSaving = ref(false);
|
||||
const articleEditorRef = ref<ArticleEditorExpose>();
|
||||
const article = ref<StoreDiary>(createEmptyArticle());
|
||||
|
||||
function createEmptyArticle(): StoreDiary {
|
||||
return {
|
||||
bizType: "GAO_STORE_DIARY",
|
||||
contentType: "TIP_TAP",
|
||||
content: "",
|
||||
title: "",
|
||||
description: "",
|
||||
attachmentList: [],
|
||||
status: "NORMAL"
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeContentType(value?: string): StoreDiary["contentType"] {
|
||||
if (value === "TEXT" || value === "MARKDOWN" || value === "TIP_TAP") {
|
||||
return value;
|
||||
}
|
||||
return "TIP_TAP";
|
||||
}
|
||||
|
||||
function updateArticle(value: ArticleEditorArticle): void {
|
||||
article.value = {
|
||||
...article.value,
|
||||
...value,
|
||||
contentType: normalizeContentType(value.contentType)
|
||||
};
|
||||
}
|
||||
|
||||
const selectedStoreName = computed(() => {
|
||||
const store = currentStoreStore.storeList.find(item => item.id === article.value.bizId)
|
||||
|| (currentStoreStore.loginStore?.id === article.value.bizId ? currentStoreStore.loginStore : undefined);
|
||||
return store?.name || store?.code || "请选择门店";
|
||||
});
|
||||
|
||||
// ---------- 媒体上传 ----------
|
||||
|
||||
async function uploadMedia(file: File): Promise<UploadResult> {
|
||||
const [tempFile] = await AttachmentAPI.upload(file);
|
||||
if (!tempFile?.id) {
|
||||
throw new Error("媒体上传失败");
|
||||
}
|
||||
|
||||
return {
|
||||
id: tempFile.id,
|
||||
url: AttachmentAPI.getTempReadURL(tempFile.id),
|
||||
name: file.name
|
||||
};
|
||||
}
|
||||
|
||||
async function uploadImage(file: File): Promise<UploadResult> {
|
||||
return await uploadMedia(await compressUploadImage(file));
|
||||
}
|
||||
|
||||
async function uploadVideo(file: File): Promise<UploadResult> {
|
||||
return await uploadMedia(file);
|
||||
}
|
||||
|
||||
function resolveAttachmentUrl(attachmentId: string): string {
|
||||
return getAttachmentReadURL(attachmentId);
|
||||
}
|
||||
|
||||
// ---------- 保存 ----------
|
||||
|
||||
function getContentText(content: string): string {
|
||||
const parser = new DOMParser();
|
||||
return parser.parseFromString(content, "text/html").body.textContent?.trim() || "";
|
||||
}
|
||||
|
||||
function hasMediaContent(content: string): boolean {
|
||||
const parser = new DOMParser();
|
||||
return Boolean(parser.parseFromString(content, "text/html").querySelector("img, video"));
|
||||
}
|
||||
|
||||
function validateArticle(value: StoreDiary): boolean {
|
||||
if (!value.bizId) {
|
||||
Message.warning("请选择门店");
|
||||
return false;
|
||||
}
|
||||
if (!value.title?.trim()) {
|
||||
Message.warning("请输入日记标题");
|
||||
return false;
|
||||
}
|
||||
|
||||
const content = value.content || "";
|
||||
if (!getContentText(content) && !hasMediaContent(content)) {
|
||||
Message.warning("请输入日记正文");
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async function save(): Promise<void> {
|
||||
if (isSaving.value) {
|
||||
return;
|
||||
}
|
||||
if (!article.value.title?.trim()) {
|
||||
Message.warning("请输入日记标题");
|
||||
return;
|
||||
}
|
||||
|
||||
isSaving.value = true;
|
||||
try {
|
||||
const editorValue = await articleEditorRef.value?.submit();
|
||||
const value: StoreDiary = {
|
||||
...article.value,
|
||||
...(editorValue || {}),
|
||||
title: article.value.title?.trim() || "",
|
||||
description: article.value.description?.trim() || "",
|
||||
contentType: normalizeContentType(editorValue?.contentType ?? article.value.contentType),
|
||||
content: editorValue?.content ?? article.value.content ?? "",
|
||||
attachmentList: editorValue?.attachmentList ?? article.value.attachmentList ?? [],
|
||||
status: article.value.status || "NORMAL"
|
||||
};
|
||||
if (!validateArticle(value)) {
|
||||
return;
|
||||
}
|
||||
|
||||
article.value = value;
|
||||
if (isCreateMode.value) {
|
||||
const id = await StoreDiaryAPI.create(value);
|
||||
Message.success("门店日记已创建");
|
||||
await router.replace({
|
||||
name: "StoreDiaryDetail",
|
||||
params: { id }
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await StoreDiaryAPI.update(value);
|
||||
Message.success("门店日记已保存");
|
||||
await router.replace({
|
||||
name: "StoreDiaryDetail",
|
||||
params: { id: routeId.value }
|
||||
});
|
||||
} catch (error) {
|
||||
Message.error(getErrorMessage(error, "门店日记保存失败"));
|
||||
} finally {
|
||||
isSaving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 页面数据 ----------
|
||||
|
||||
async function prepareStoreScope(): Promise<void> {
|
||||
const store = await currentStoreStore.loadLoginStore();
|
||||
if (store?.id) {
|
||||
article.value.bizId = store.id;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadArticle(): Promise<void> {
|
||||
await prepareStoreScope();
|
||||
if (isCreateMode.value) {
|
||||
article.value = {
|
||||
...createEmptyArticle(),
|
||||
bizId: article.value.bizId || currentStoreStore.currentStoreId
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await StoreDiaryAPI.detail(routeId.value);
|
||||
article.value = {
|
||||
...result,
|
||||
title: result.title || "",
|
||||
description: result.description || "",
|
||||
contentType: result.contentType || "TIP_TAP",
|
||||
content: result.content || "",
|
||||
attachmentList: result.attachmentList || []
|
||||
};
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (userStore.hasPermissions(savePermissionCode.value)) {
|
||||
navBarStore.setRightText("保存", "var(--td-brand-color)", save);
|
||||
}
|
||||
try {
|
||||
await loadArticle();
|
||||
} catch (error) {
|
||||
Message.error(getErrorMessage(error, "门店日记加载失败"));
|
||||
}
|
||||
});
|
||||
|
||||
onUnmounted(() => navBarStore.clearRight());
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.store-diary-edit {
|
||||
min-height: 100%;
|
||||
padding-top: var(--app-nav-height);
|
||||
|
||||
.form {
|
||||
margin: 0;
|
||||
border-radius: 0;
|
||||
|
||||
.picker-text {
|
||||
width: 100%;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.editor-wrapper {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
overflow: visible;
|
||||
|
||||
.editor {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
:deep(.tp-editor) {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
:deep(.content) {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
:deep(.toolbar) {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
top: var(--app-nav-height);
|
||||
z-index: 10;
|
||||
position: sticky;
|
||||
}
|
||||
|
||||
:deep(.toolbar .scroll) {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,505 @@
|
||||
<template>
|
||||
<div v-can="permissionCodes.storeDiary.read" class="store-diary-list">
|
||||
<t-pull-down-refresh
|
||||
class="list-wrapper"
|
||||
v-model="isRefreshing"
|
||||
:refresh-timeout="30000"
|
||||
@refresh="refresh"
|
||||
@scrolltolower="loadMore"
|
||||
>
|
||||
<section class="calendar" v-entrance-transition>
|
||||
<t-calendar
|
||||
:key="calendarRenderKey"
|
||||
class="calendar-view"
|
||||
:value="selectedCalendarDate"
|
||||
:format="calendarFormat"
|
||||
switch-mode="month"
|
||||
:use-popup="false"
|
||||
@panel-change="handleCalendarPanelChange"
|
||||
@select="selectCalendarDate"
|
||||
/>
|
||||
</section>
|
||||
<t-cell-group v-if="pageResult.list.length" class="items fix-last-cell-line" theme="card">
|
||||
<t-swipe-cell
|
||||
v-for="(item, index) in pageResult.list"
|
||||
:key="item.id || index"
|
||||
class="item"
|
||||
v-entrance-transition="index * 30"
|
||||
>
|
||||
<t-cell
|
||||
class="cell"
|
||||
:title="getTitle(item)"
|
||||
:description="getDescription(item)"
|
||||
arrow
|
||||
@click="toDetail(item)"
|
||||
>
|
||||
<template #note>
|
||||
<pass-time-label :timestamp="item.updatedAt || item.createdAt" underline />
|
||||
</template>
|
||||
</t-cell>
|
||||
<template v-if="canDelete" #right>
|
||||
<t-button
|
||||
class="swipe-cell-button"
|
||||
theme="danger"
|
||||
:loading="isDeleting && deletingId === item.id"
|
||||
@click.stop="confirmDelete(item)"
|
||||
>
|
||||
删除
|
||||
</t-button>
|
||||
</template>
|
||||
</t-swipe-cell>
|
||||
</t-cell-group>
|
||||
<div class="load-state" v-text="loadStateText" />
|
||||
</t-pull-down-refresh>
|
||||
</div>
|
||||
<nav-bar-action-menu v-if="canShowActionMenu" :actions="actionList" />
|
||||
<bottom-popup
|
||||
v-model="diaryPopupVisible"
|
||||
:title="`${selectedDateText} 门店日记`"
|
||||
height="min(80vh, 40rem)"
|
||||
left-button-text="关闭"
|
||||
right-button-text=""
|
||||
@left-button-click="closeDiaryPopup"
|
||||
>
|
||||
<div class="diary-popup">
|
||||
<t-cell-group v-if="selectedDateDiaries.length" theme="card">
|
||||
<t-cell
|
||||
v-for="(item, index) in selectedDateDiaries"
|
||||
:key="item.id || index"
|
||||
class="cell"
|
||||
:title="getTitle(item)"
|
||||
:description="getDescription(item)"
|
||||
arrow
|
||||
hover
|
||||
@click="toSelectedDiary(item)"
|
||||
>
|
||||
<template #note>
|
||||
<pass-time-label :timestamp="item.updatedAt || item.createdAt" underline />
|
||||
</template>
|
||||
</t-cell>
|
||||
</t-cell-group>
|
||||
<div v-else class="popup-empty" v-text="'该日期暂无门店日记'" />
|
||||
</div>
|
||||
</bottom-popup>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { BottomPopup } from "timi-tdesign-mobile";
|
||||
import { type CalendarProps, DialogPlugin, type TDate } from "tdesign-mobile-vue";
|
||||
import { type PageResult, useUserStore } from "timi-web-api";
|
||||
import { PassTimeLabel, Time } from "timi-web";
|
||||
import StoreDiaryAPI, { type StoreDiaryListOptions } from "@/api/StoreDiaryAPI";
|
||||
import { useCurrentStoreStore } from "@/store/currentStoreStore";
|
||||
import { useNavBarStore } from "@/store/navBarStore";
|
||||
import type { NavBarAction } from "@/types/NavBarAction";
|
||||
import type { StoreDiary } from "@/types/StoreDiary";
|
||||
import { getErrorMessage } from "@/utils/error";
|
||||
import { Message } from "@/utils/message";
|
||||
import { permissionCodes } from "@/utils/permission";
|
||||
|
||||
defineOptions({
|
||||
name: "StoreDiaryList"
|
||||
});
|
||||
|
||||
const router = useRouter();
|
||||
const navBarStore = useNavBarStore();
|
||||
const userStore = useUserStore();
|
||||
const currentStoreStore = useCurrentStoreStore();
|
||||
const canCreate = computed(() => userStore.hasPermissions(permissionCodes.storeDiary.create));
|
||||
const canDelete = computed(() => userStore.hasPermissions(permissionCodes.storeDiary.delete));
|
||||
const canShowActionMenu = computed(() => canCreate.value);
|
||||
|
||||
// ---------- 日历 ----------
|
||||
|
||||
const today = new Date();
|
||||
const selectedCalendarDate = ref<Date>(today);
|
||||
const calendarMonth = reactive({
|
||||
year: today.getFullYear(),
|
||||
month: today.getMonth() + 1
|
||||
});
|
||||
const calendarRenderKey = ref(0);
|
||||
const calendarDiaryList = ref<StoreDiary[]>([]);
|
||||
const isCalendarLoading = ref(false);
|
||||
const diaryPopupVisible = ref(false);
|
||||
const selectedDateText = ref("");
|
||||
const selectedDateDiaries = ref<StoreDiary[]>([]);
|
||||
let calendarLoadToken = 0;
|
||||
|
||||
const diaryDateMap = computed(() => {
|
||||
const map = new Map<string, StoreDiary[]>();
|
||||
for (const item of calendarDiaryList.value) {
|
||||
const dateText = getDiaryDateText(item);
|
||||
if (!dateText) {
|
||||
continue;
|
||||
}
|
||||
const diaries = map.get(dateText) || [];
|
||||
diaries.push(item);
|
||||
map.set(dateText, diaries);
|
||||
}
|
||||
|
||||
for (const diaries of map.values()) {
|
||||
diaries.sort((left, right) => getDiaryTimestamp(right) - getDiaryTimestamp(left));
|
||||
}
|
||||
return map;
|
||||
});
|
||||
|
||||
const calendarFormat = computed<NonNullable<CalendarProps["format"]>>(() => {
|
||||
const dateMap = diaryDateMap.value;
|
||||
return (dateItem: TDate) => {
|
||||
const diaries = dateMap.get(Time.formatDate(dateItem.date)) || [];
|
||||
if (diaries.length < 1) {
|
||||
return dateItem;
|
||||
}
|
||||
return {
|
||||
...dateItem,
|
||||
className: "has-diary",
|
||||
suffix: String(diaries.length)
|
||||
};
|
||||
};
|
||||
});
|
||||
|
||||
// ---------- 顶部操作 ----------
|
||||
|
||||
const actionList = computed<NavBarAction[]>(() => {
|
||||
if (!canCreate.value) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [{
|
||||
action: toCreate,
|
||||
theme: "primary",
|
||||
icon: "add",
|
||||
key: "create",
|
||||
text: "新增日记"
|
||||
}];
|
||||
});
|
||||
|
||||
// ---------- 列表数据 ----------
|
||||
|
||||
const page = reactive({
|
||||
index: 0,
|
||||
size: 20
|
||||
});
|
||||
const pageResult = reactive<PageResult<StoreDiary>>({
|
||||
total: 0,
|
||||
list: []
|
||||
});
|
||||
const isRefreshing = ref(false);
|
||||
const isLoading = ref(false);
|
||||
const isNoMore = ref(false);
|
||||
const isDeleting = ref(false);
|
||||
const deletingId = ref("");
|
||||
let loadToken = 0;
|
||||
|
||||
const loadStateText = computed(() => {
|
||||
if (isLoading.value) {
|
||||
return "正在加载门店日记";
|
||||
}
|
||||
if (isNoMore.value) {
|
||||
return pageResult.list.length ? `共 ${pageResult.total} 篇日记` : "暂无门店日记";
|
||||
}
|
||||
return pageResult.list.length ? "上拉加载更多" : "暂无门店日记";
|
||||
});
|
||||
|
||||
function getCurrentMonthListOptions(): StoreDiaryListOptions {
|
||||
const beginDate = new Date(calendarMonth.year, calendarMonth.month - 1, 1, 0, 0, 0, 0);
|
||||
const endDate = new Date(calendarMonth.year, calendarMonth.month, 1, 0, 0, 0, 0);
|
||||
return {
|
||||
beginCreatedAt: Time.toTimestamp(beginDate),
|
||||
endCreatedAt: Time.toTimestamp(endDate),
|
||||
storeId: currentStoreStore.currentStoreId
|
||||
};
|
||||
}
|
||||
|
||||
async function loadCalendarDiaries(): Promise<void> {
|
||||
const requestToken = calendarLoadToken + 1;
|
||||
calendarLoadToken = requestToken;
|
||||
isCalendarLoading.value = true;
|
||||
try {
|
||||
const result = await StoreDiaryAPI.list({
|
||||
index: 0,
|
||||
size: 1000
|
||||
}, getCurrentMonthListOptions());
|
||||
if (requestToken !== calendarLoadToken) {
|
||||
return;
|
||||
}
|
||||
|
||||
calendarDiaryList.value = result.list || [];
|
||||
calendarRenderKey.value += 1;
|
||||
} finally {
|
||||
if (requestToken === calendarLoadToken) {
|
||||
isCalendarLoading.value = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMore(): Promise<void> {
|
||||
if (isLoading.value || isNoMore.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const requestToken = loadToken + 1;
|
||||
loadToken = requestToken;
|
||||
isLoading.value = true;
|
||||
try {
|
||||
const result = await StoreDiaryAPI.list(page, getCurrentMonthListOptions());
|
||||
if (requestToken !== loadToken) {
|
||||
return;
|
||||
}
|
||||
|
||||
const list = result.list || [];
|
||||
pageResult.total = result.total || 0;
|
||||
pageResult.list = pageResult.list.concat(list);
|
||||
page.index += 1;
|
||||
isNoMore.value = pageResult.list.length >= pageResult.total || list.length < page.size;
|
||||
} catch (error) {
|
||||
Message.error(getErrorMessage(error, "门店日记加载失败"));
|
||||
} finally {
|
||||
if (requestToken === loadToken) {
|
||||
isLoading.value = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function reload(): Promise<void> {
|
||||
loadToken += 1;
|
||||
page.index = 0;
|
||||
pageResult.total = 0;
|
||||
pageResult.list = [];
|
||||
isNoMore.value = false;
|
||||
await loadMore();
|
||||
}
|
||||
|
||||
async function refresh(): Promise<void> {
|
||||
isRefreshing.value = true;
|
||||
try {
|
||||
await Promise.all([reload(), loadCalendarDiaries()]);
|
||||
} finally {
|
||||
isRefreshing.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 展示 ----------
|
||||
|
||||
function getDiaryTimestamp(item: StoreDiary): number {
|
||||
return item.createdAt || item.updatedAt || 0;
|
||||
}
|
||||
|
||||
function getDiaryDateText(item: StoreDiary): string {
|
||||
const date = Time.toDateObject(getDiaryTimestamp(item));
|
||||
return date ? Time.formatDate(date) : "";
|
||||
}
|
||||
|
||||
function getTitle(item: StoreDiary): string {
|
||||
return item.title?.trim() || "未命名日记";
|
||||
}
|
||||
|
||||
function getDescription(item: StoreDiary): string {
|
||||
return item.description?.trim() || "暂无摘要";
|
||||
}
|
||||
|
||||
// ---------- 导航 ----------
|
||||
|
||||
async function handleCalendarPanelChange(context: { year: number; month: number }): Promise<void> {
|
||||
if (context.year === calendarMonth.year && context.month === calendarMonth.month) {
|
||||
return;
|
||||
}
|
||||
calendarMonth.year = context.year;
|
||||
calendarMonth.month = context.month;
|
||||
selectedCalendarDate.value = new Date(context.year, context.month - 1, 1);
|
||||
diaryPopupVisible.value = false;
|
||||
await Promise.all([reload(), loadCalendarDiaries()]);
|
||||
}
|
||||
|
||||
async function selectCalendarDate(date: Date): Promise<void> {
|
||||
selectedCalendarDate.value = date;
|
||||
const diaries = diaryDateMap.value.get(Time.formatDate(date)) || [];
|
||||
if (diaries.length < 1) {
|
||||
return;
|
||||
}
|
||||
if (diaries.length < 2) {
|
||||
const [diary] = diaries;
|
||||
if (diary) {
|
||||
await toDetail(diary);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
selectedDateText.value = Time.formatDate(date);
|
||||
selectedDateDiaries.value = [...diaries];
|
||||
diaryPopupVisible.value = true;
|
||||
}
|
||||
|
||||
function closeDiaryPopup(): void {
|
||||
diaryPopupVisible.value = false;
|
||||
}
|
||||
|
||||
async function toSelectedDiary(item: StoreDiary): Promise<void> {
|
||||
closeDiaryPopup();
|
||||
await toDetail(item);
|
||||
}
|
||||
|
||||
async function toCreate(): Promise<void> {
|
||||
await router.push({ name: "StoreDiaryCreate" });
|
||||
}
|
||||
|
||||
async function toDetail(item: StoreDiary): Promise<void> {
|
||||
if (!item.id) {
|
||||
return;
|
||||
}
|
||||
await router.push({
|
||||
name: "StoreDiaryDetail",
|
||||
params: { id: item.id }
|
||||
});
|
||||
}
|
||||
|
||||
// ---------- 删除 ----------
|
||||
|
||||
async function confirmDelete(item: StoreDiary): Promise<void> {
|
||||
if (!item.id || !canDelete.value || isDeleting.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const diaryId = item.id;
|
||||
const dialog = DialogPlugin.confirm({
|
||||
title: "删除门店日记",
|
||||
content: `确定删除“${getTitle(item)}”吗?删除后无法恢复。`,
|
||||
cancelBtn: "取消",
|
||||
confirmBtn: "删除",
|
||||
onCancel() {
|
||||
dialog.destroy();
|
||||
},
|
||||
async onConfirm() {
|
||||
dialog.destroy();
|
||||
isDeleting.value = true;
|
||||
deletingId.value = diaryId;
|
||||
try {
|
||||
await StoreDiaryAPI.remove(diaryId);
|
||||
await Promise.all([reload(), loadCalendarDiaries()]);
|
||||
Message.success("门店日记已删除");
|
||||
} catch (error) {
|
||||
Message.error(getErrorMessage(error, "门店日记删除失败"));
|
||||
} finally {
|
||||
isDeleting.value = false;
|
||||
deletingId.value = "";
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ---------- 生命周期 ----------
|
||||
|
||||
async function preparePage(): Promise<void> {
|
||||
await currentStoreStore.loadLoginStore();
|
||||
await Promise.all([reload(), loadCalendarDiaries()]);
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
navBarStore.clearRight();
|
||||
navBarStore.setCustomRightVisible(canShowActionMenu.value);
|
||||
try {
|
||||
await preparePage();
|
||||
} catch (error) {
|
||||
Message.error(getErrorMessage(error, "门店日记页面加载失败"));
|
||||
}
|
||||
});
|
||||
|
||||
onUnmounted(() => navBarStore.clearRight());
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.store-diary-list {
|
||||
padding: calc(var(--app-nav-height) + 1rem) 0 1rem;
|
||||
|
||||
.list-wrapper {
|
||||
min-height: 70vh;
|
||||
|
||||
.calendar {
|
||||
margin: 0 1rem 1rem;
|
||||
overflow: hidden;
|
||||
background: var(--td-bg-color-container);
|
||||
box-shadow: var(--td-shadow-1);
|
||||
border-radius: .75rem;
|
||||
|
||||
:deep(.t-calendar__title) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
:deep(.t-calendar-header) {
|
||||
padding-top: .5rem;
|
||||
}
|
||||
|
||||
:deep(.t-calendar) {
|
||||
overflow: hidden;
|
||||
border-radius: .75rem;
|
||||
|
||||
&__months {
|
||||
height: auto;
|
||||
}
|
||||
|
||||
&__dates-item {
|
||||
height: 2.5rem;
|
||||
margin-top: 0;
|
||||
align-items: unset;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.has-diary) {
|
||||
font-weight: 700;
|
||||
--td-calendar-item-suffix-color: var(--td-brand-color);
|
||||
}
|
||||
}
|
||||
|
||||
.items {
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
.load-state {
|
||||
padding: 1rem;
|
||||
color: var(--td-text-color-placeholder);
|
||||
font-size: .8125rem;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.cell {
|
||||
:deep(.t-cell__title) {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
:deep(.t-cell__title-text),
|
||||
:deep(.t-cell__description) {
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
:deep(.t-cell__note) {
|
||||
flex: 0 0 auto;
|
||||
min-width: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.diary-popup {
|
||||
padding-bottom: .5rem;
|
||||
|
||||
.popup-empty {
|
||||
padding: 2rem 1rem;
|
||||
color: var(--td-text-color-placeholder);
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
:global(html.theme-dark) {
|
||||
.store-diary-list {
|
||||
.description {
|
||||
color: #94a3b8;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -34,6 +34,19 @@
|
||||
<t-icon name="shop-3" />
|
||||
</template>
|
||||
</t-cell>
|
||||
<t-cell
|
||||
v-if="canManageStoreDiary"
|
||||
class="action"
|
||||
title="门店日记"
|
||||
note="记录门店经营和护理过程"
|
||||
arrow
|
||||
hover
|
||||
@click="goStoreDiaryManagement"
|
||||
>
|
||||
<template #leftIcon>
|
||||
<t-icon name="file" />
|
||||
</template>
|
||||
</t-cell>
|
||||
</t-cell-group>
|
||||
</section>
|
||||
<store-data-charts :key="`${currentStoreStore.currentStoreId}-${chartRefreshKey}`" />
|
||||
@@ -79,6 +92,9 @@ const canManageStore = computed(() => userStore.hasRoles([
|
||||
gaoRoleCodes.globalManager,
|
||||
gaoRoleCodes.storeManager
|
||||
], "or") && userStore.hasPermissions(permissionCodes.store.read));
|
||||
const canManageStoreDiary = computed(() => userStore.hasRoles(gaoRoleCodes.storeManager)
|
||||
&& !userStore.hasRoles(gaoRoleCodes.globalManager)
|
||||
&& userStore.hasPermissions(permissionCodes.storeDiary.read));
|
||||
|
||||
// ---------- 门店切换 ----------
|
||||
|
||||
@@ -138,6 +154,20 @@ async function goStoreManagement(): Promise<void> {
|
||||
await router.push(`/management/stores/${store.id}`);
|
||||
}
|
||||
|
||||
async function goStoreDiaryManagement(): Promise<void> {
|
||||
if (!canManageStoreDiary.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const store = await currentStoreStore.loadLoginStore();
|
||||
if (!store?.id) {
|
||||
Message.warning("未绑定所属门店");
|
||||
return;
|
||||
}
|
||||
|
||||
await router.push({ name: "StoreDiaryList" });
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
navBarStore.setRightIcon("refresh", refreshCharts);
|
||||
if (canSelectStore.value) {
|
||||
|
||||
Reference in New Issue
Block a user