update notify style

This commit is contained in:
Timi
2026-08-28 11:39:12 +08:00
parent f54a129edb
commit bbca267242
10 changed files with 483 additions and 447 deletions
-136
View File
@@ -1,136 +0,0 @@
<template>
<t-popup v-model="visible" placement="bottom">
<div class="bottom-popup" :style="{height: props.height}">
<div v-if="hasHeader" class="header">
<div class="button left">
<slot name="leftButton">
<t-button v-if="leftButtonText" variant="text" size="small" @click="emitLeftButtonClick">
<span v-text="leftButtonText" />
</t-button>
</slot>
</div>
<div class="title">
<slot name="title">
<span v-text="title" />
</slot>
</div>
<div class="button right">
<slot name="rightButton">
<t-button v-if="rightButtonText" theme="primary" variant="text" size="small" @click="emitRightButtonClick">
<span v-text="rightButtonText" />
</t-button>
</slot>
</div>
</div>
<div class="body">
<slot />
</div>
</div>
</t-popup>
</template>
<script setup lang="ts">
defineOptions({
name: "BottomPopup"
});
interface Props {
modelValue?: boolean;
leftButtonText?: string;
rightButtonText?: string;
height?: string | number;
title?: string;
}
const props = withDefaults(defineProps<Props>(), {
modelValue: false,
leftButtonText: "取消",
rightButtonText: "确定",
height: "auto",
title: ""
});
const emit = defineEmits<{
"update:modelValue": [value: boolean];
leftButtonClick: [];
rightButtonClick: [];
}>();
const slots = useSlots();
const visible = computed({
get: () => props.modelValue,
set: value => emit("update:modelValue", value)
});
const hasHeader = computed(() => Boolean(
props.title
|| props.leftButtonText
|| props.rightButtonText
|| slots.title
|| slots.leftButton
|| slots.rightButton
));
function emitLeftButtonClick(): void {
emit("leftButtonClick");
}
function emitRightButtonClick(): void {
emit("rightButtonClick");
}
defineSlots<{
default?: () => unknown;
title?: () => unknown;
leftButton?: () => unknown;
rightButton?: () => unknown;
}>();
</script>
<style scoped lang="less">
.bottom-popup {
display: flex;
overflow: hidden;
background: var(--td-bg-color-container, #FFF);
padding-top: 1rem;
border-radius: 1.25rem 1.25rem 0 0;
flex-direction: column;
.header {
flex: none;
padding: 0 1rem;
display: grid;
align-items: center;
grid-template-columns: 4rem 1fr 4rem;
}
.button {
min-width: 0;
display: flex;
&.right {
justify-content: flex-end;
}
}
.title {
margin: 0;
overflow: hidden;
text-align: center;
white-space: nowrap;
text-overflow: ellipsis;
}
.body {
flex: 1;
padding: .75rem 0 env(safe-area-inset-bottom, 0);
overflow: auto;
min-height: 0;
}
}
:global(html.theme-dark) {
.bottom-popup {
background: #0f172a;
}
}
</style>
+1
View File
@@ -155,6 +155,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { BottomPopup } from "timi-tdesign-mobile";
import { Message } from "@/utils/message"; import { Message } from "@/utils/message";
import { PassTimeLabel, Text } from "timi-web"; import { PassTimeLabel, Text } from "timi-web";
import { useUserStore, type Attachment } from "timi-web-api"; import { useUserStore, type Attachment } from "timi-web-api";
+1
View File
@@ -173,6 +173,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { BottomPopup } from "timi-tdesign-mobile";
import { type Attachment, AttachmentAPI, useUserStore } from "timi-web-api"; import { type Attachment, AttachmentAPI, useUserStore } from "timi-web-api";
import { Message } from "@/utils/message"; import { Message } from "@/utils/message";
import CustomerAPI from "@/api/CustomerAPI"; import CustomerAPI from "@/api/CustomerAPI";
+10 -1
View File
@@ -9,7 +9,11 @@
<pass-time-label class="time" :timestamp="detail.sendAt" underline /> <pass-time-label class="time" :timestamp="detail.sendAt" underline />
</header> </header>
<section class="body"> <section class="body">
<notify-view :detail="detail" empty-text="暂无通知内容" /> <notify-view
:detail="detail"
empty-text="暂无通知内容"
:resolve-attachment-url="resolveAttachmentUrl"
/>
</section> </section>
</article> </article>
<t-empty <t-empty
@@ -26,6 +30,7 @@ import { NotifyAPI, type NotifyDetail } from "timi-web-api";
import { DialogPlugin } from "tdesign-mobile-vue"; import { DialogPlugin } from "tdesign-mobile-vue";
import { useNavBarStore } from "@/store/navBarStore"; import { useNavBarStore } from "@/store/navBarStore";
import { getErrorMessage } from "@/utils/error"; import { getErrorMessage } from "@/utils/error";
import { getAttachmentReadURL } from "@/utils/attachment";
import { corePermissionCodes } from "@/utils/permission"; import { corePermissionCodes } from "@/utils/permission";
import { Message } from "@/utils/message.ts"; import { Message } from "@/utils/message.ts";
@@ -148,6 +153,10 @@ function getSenderName(item: NotifyDetail): string {
return item.fromUser?.nick?.trim() || item.fromUser?.name?.trim() || "系统通知"; return item.fromUser?.nick?.trim() || item.fromUser?.name?.trim() || "系统通知";
} }
function resolveAttachmentUrl(attachmentId: string): string {
return getAttachmentReadURL(attachmentId);
}
// ---------- 生命周期 ---------- // ---------- 生命周期 ----------
watch(() => route.params.id, async () => { watch(() => route.params.id, async () => {
+82 -32
View File
@@ -10,17 +10,12 @@
placeholder="请输入通知标题" placeholder="请输入通知标题"
/> />
</t-form-item> </t-form-item>
<notify-recipient-scope-picker <t-form-item
v-if="isGlobalScope" label="接收用户"
v-model="selectedStoreId" :description="selectedRecipientDescription"
:options="storeScopeOptions" required-mark
@update:model-value="handleStoreScopeChange" arrow
/> @click="openRecipientPicker"
<notify-recipient-picker
v-model="selectedUserIdList"
:users="recipientList"
:self-user="selfRecipient"
:loading="isRecipientLoading"
/> />
<t-form-item label="发送时间" arrow @click="openSchedulePicker"> <t-form-item label="发送时间" arrow @click="openSchedulePicker">
<div class="schedule-value"> <div class="schedule-value">
@@ -39,10 +34,20 @@
class="editor" class="editor"
v-model="draftDetail" v-model="draftDetail"
placeholder="请输入通知内容" placeholder="请输入通知内容"
:upload-image="uploadImage"
:upload-video="uploadVideo"
/> />
</section> </section>
</t-form> </t-form>
</div> </div>
<notify-recipient-picker
v-model="selectedUserIdList"
v-model:visible="recipientPickerVisible"
:users="recipientList"
:stores="storeScopeOptions"
:self-user="selfRecipient"
:loading="isRecipientLoading"
/>
<t-popup v-model="schedulePickerVisible" placement="bottom"> <t-popup v-model="schedulePickerVisible" placement="bottom">
<t-date-time-picker <t-date-time-picker
v-model="scheduleDraftText" v-model="scheduleDraftText"
@@ -59,18 +64,15 @@
<script setup lang="ts"> <script setup lang="ts">
import { NotifyEditor, type NotifyEditorExpose } from "timi-tdesign-mobile"; import { NotifyEditor, type NotifyEditorExpose } from "timi-tdesign-mobile";
import { Time } from "timi-web"; import { Time } from "timi-web";
import { type Attachment as ApiAttachment, type NotifyContentType, type NotifyDetail, useUserStore } from "timi-web-api"; import { AttachmentAPI, type Attachment as ApiAttachment, type NotifyContentType, type NotifyDetail, useUserStore } from "timi-web-api";
import { Message } from "@/utils/message"; import { Message } from "@/utils/message";
import { getErrorMessage } from "@/utils/error"; import { getErrorMessage } from "@/utils/error";
import GaoNotifyAPI from "@/api/GaoNotifyAPI"; import GaoNotifyAPI from "@/api/GaoNotifyAPI";
import type { ManagedUser } from "@/types/ManagedUser"; import type { ManagedUser } from "@/types/ManagedUser";
import NotifyRecipientPicker, { type NotifyRecipientScopeOption } from "@/pages/notify/component/NotifyRecipientPicker.vue";
import { corePermissionCodes, coreRoleCodes, gaoRoleCodes, permissionCodes } from "@/utils/permission"; import { corePermissionCodes, coreRoleCodes, gaoRoleCodes, permissionCodes } from "@/utils/permission";
import { useNavBarStore } from "@/store/navBarStore"; import { useNavBarStore } from "@/store/navBarStore";
import { useCurrentStoreStore } from "@/store/currentStoreStore"; import { useCurrentStoreStore } from "@/store/currentStoreStore";
import NotifyRecipientScopePicker, { type NotifyRecipientScopeOption } from "@/pages/notify/component/NotifyRecipientScopePicker.vue";
import NotifyRecipientPicker from "@/pages/notify/component/NotifyRecipientPicker.vue";
const allStoreValue = "__ALL_STORES__";
defineOptions({ defineOptions({
name: "NotifyManagementCreate" name: "NotifyManagementCreate"
@@ -95,12 +97,33 @@ const draftDetail = ref<NotifyDetail>({
}); });
const selectedUserIdList = ref<string[]>([]); const selectedUserIdList = ref<string[]>([]);
const isSaving = ref(false); const isSaving = ref(false);
const selectedStoreId = ref(allStoreValue);
async function uploadMedia(file: File): Promise<{ id: string; url: string; name: string }> {
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) {
return await uploadMedia(file);
}
async function uploadVideo(file: File) {
return await uploadMedia(file);
}
// ---------- 接收用户 ---------- // ---------- 接收用户 ----------
const isRecipientLoading = ref(false); const isRecipientLoading = ref(false);
const recipientList = ref<ManagedUser[]>([]); const recipientList = ref<ManagedUser[]>([]);
const recipientPickerVisible = ref(false);
const recipientStoreId = ref("");
const loginUserId = computed(() => userStore.loginUser.user?.id || userStore.loginUser.id || ""); const loginUserId = computed(() => userStore.loginUser.user?.id || userStore.loginUser.id || "");
const selfRecipient = computed<ManagedUser | undefined>(() => { const selfRecipient = computed<ManagedUser | undefined>(() => {
if (!loginUserId.value) { if (!loginUserId.value) {
@@ -114,20 +137,41 @@ const selfRecipient = computed<ManagedUser | undefined>(() => {
id: user.id, id: user.id,
name: user.name, name: user.name,
nick: user.nick, nick: user.nick,
phoneNo: user.phoneNo phoneNo: user.phoneNo,
attachmentList: user.attachmentList as unknown as ApiAttachment[]
} : undefined } : undefined
}; };
}); });
const storeScopeOptions = computed<NotifyRecipientScopeOption[]>(() => [ const storeScopeOptions = computed<NotifyRecipientScopeOption[]>(() => {
...(isGlobalScope.value ? [{ label: "所有门店", value: allStoreValue }] : []), const storeList = currentStoreStore.storeList.length
...currentStoreStore.storeList ? currentStoreStore.storeList
: currentStoreStore.currentStore
? [currentStoreStore.currentStore]
: [];
return storeList
.map(item => ({ .map(item => ({
label: item.name || item.code || "未命名门店", label: item.name || item.code || "未命名门店",
value: item.id || "" value: item.id || ""
})) }))
.filter(item => item.value) .filter(item => item.value);
]); });
const availableRecipientList = computed(() => {
const selfUserId = selfRecipient.value?.userId;
const userList = selfUserId
? recipientList.value.filter(item => item.userId !== selfUserId)
: recipientList.value;
return selfRecipient.value ? [selfRecipient.value, ...userList] : userList;
});
const selectedRecipientDescription = computed(() => {
if (!selectedUserIdList.value.length) {
return "请选择接收用户";
}
const recipientMap = new Map(availableRecipientList.value.map(item => [item.userId, item]));
return selectedUserIdList.value
.map(userId => getRecipientName(recipientMap.get(userId) || { userId }))
.join("、");
});
// ---------- 定时发送 ---------- // ---------- 定时发送 ----------
@@ -236,6 +280,19 @@ function clearSchedule(): void {
scheduleDraftText.value = ""; scheduleDraftText.value = "";
} }
// ---------- 接收用户选择 ----------
function openRecipientPicker(): void {
recipientPickerVisible.value = true;
}
function getRecipientName(item: ManagedUser): string {
if (item.userId === selfRecipient.value?.userId) {
return `自己(${item.user?.nick || item.user?.name || "当前账号"})`;
}
return item.user?.nick || item.user?.name || item.userId || "未命名用户";
}
// ---------- 接收用户 ---------- // ---------- 接收用户 ----------
async function loadRecipients(): Promise<void> { async function loadRecipients(): Promise<void> {
@@ -249,9 +306,7 @@ async function loadRecipients(): Promise<void> {
const list: ManagedUser[] = []; const list: ManagedUser[] = [];
let pageIndex = 0; let pageIndex = 0;
let total = 0; let total = 0;
const storeId = selectedStoreId.value && selectedStoreId.value !== allStoreValue const storeId = recipientStoreId.value || undefined;
? selectedStoreId.value
: undefined;
while (true) { while (true) {
const result = await GaoNotifyAPI.listRecipients( const result = await GaoNotifyAPI.listRecipients(
{ index: pageIndex, size: pageSize }, { index: pageIndex, size: pageSize },
@@ -280,11 +335,6 @@ async function loadRecipients(): Promise<void> {
} }
} }
async function handleStoreScopeChange(): Promise<void> {
selectedUserIdList.value = [];
await loadRecipients();
}
// ---------- 生命周期 ---------- // ---------- 生命周期 ----------
onMounted(async () => { onMounted(async () => {
@@ -296,7 +346,7 @@ onMounted(async () => {
await currentStoreStore.loadStoreList(); await currentStoreStore.loadStoreList();
} else { } else {
const loginStore = await currentStoreStore.loadLoginStore(); const loginStore = await currentStoreStore.loadLoginStore();
selectedStoreId.value = loginStore?.id || currentStoreStore.currentStoreId || ""; recipientStoreId.value = loginStore?.id || currentStoreStore.currentStoreId || "";
} }
navBarStore.setRightText("发送", "var(--td-brand-color)", saveNotification); navBarStore.setRightText("发送", "var(--td-brand-color)", saveNotification);
await loadRecipients(); await loadRecipients();
@@ -15,6 +15,7 @@
v-if="mainDetail" v-if="mainDetail"
:detail="mainDetail" :detail="mainDetail"
empty-text="暂无通知内容" empty-text="暂无通知内容"
:resolve-attachment-url="resolveAttachmentUrl"
/> />
<div v-else class="empty-content">暂无通知内容</div> <div v-else class="empty-content">暂无通知内容</div>
</section> </section>
@@ -75,6 +76,7 @@ import { NotifyAPI, type Notify, type NotifyDetail, useUserStore } from "timi-we
import { DialogPlugin } from "tdesign-mobile-vue"; import { DialogPlugin } from "tdesign-mobile-vue";
import { Message } from "@/utils/message"; import { Message } from "@/utils/message";
import { getErrorMessage } from "@/utils/error"; import { getErrorMessage } from "@/utils/error";
import { getAttachmentReadURL } from "@/utils/attachment";
import { useNavBarStore } from "@/store/navBarStore"; import { useNavBarStore } from "@/store/navBarStore";
import { corePermissionCodes } from "@/utils/permission"; import { corePermissionCodes } from "@/utils/permission";
@@ -145,6 +147,10 @@ const getSummaryTheme = computed<NotifyTagTheme>(() => {
return "success"; return "success";
}); });
function resolveAttachmentUrl(attachmentId: string): string {
return getAttachmentReadURL(attachmentId);
}
async function loadDetail(): Promise<void> { async function loadDetail(): Promise<void> {
const requestToken = loadToken + 1; const requestToken = loadToken + 1;
loadToken = requestToken; loadToken = requestToken;
@@ -1,73 +1,153 @@
<template> <template>
<t-form-item <bottom-popup
label="接收用户" v-model="popupVisible"
required-mark title="选择接收用户"
:arrow="true" right-button-text="完成"
@click="openPicker" height="80vh"
@left-button-click="handleLeftButton"
@right-button-click="confirmSelection"
> >
<div class="selected-text" v-text="selectedText" /> <template #leftButton>
</t-form-item> <t-button variant="text" size="small" @click="handleLeftButton">
<t-popup v-model="visible" placement="bottom" destroy-on-close> <span v-text="pickerStoreId ? '返回' : '取消'" />
<div class="recipient-picker"> </t-button>
<header class="picker-header"> </template>
<t-button variant="text" size="small" @click="cancelPicker">取消</t-button> <div class="page-transition">
<h3>选择接收用户</h3> <transition :name="pageTransitionName">
<t-button theme="primary" variant="text" size="small" @click="confirmPicker">确定</t-button> <div v-if="!pickerStoreId" key="store-list" class="recipient-picker store">
</header> <div class="summary">
<div class="search-wrapper"> <span v-text="`已选择 ${draftSelectedUserIdList.length} 位用户`" />
<t-input <span v-text="loading ? '正在加载' : '勾选门店可选择全部用户'" />
v-model.trim="keyword" </div>
class="search" <t-cell-group v-if="stores.length || selfUser" class="list">
borderless <t-cell
clearable v-if="selfUser"
placeholder="搜索姓名、昵称或手机号" class="item"
/> title="当前账号"
</div> description="可接收自己发送的通知"
<div class="selection-actions"> @click="toggleSelfRecipient(!isSelfRecipientSelected())"
<span class="selection-count" v-text="`已选择 ${selectedUserIdList.length} 人`" /> >
<t-button <template #left-icon>
variant="text" <div class="leading">
size="small" <div @click.stop>
:disabled="!filteredIdList.length" <t-checkbox
@click="toggleSelectAll" class="checkbox"
> :model-value="draftSelectedUserIdList.includes(selfUser.userId || '')"
<span v-text="allFilteredSelected ? '取消全选' : '全选当前结果'" /> borderless
</t-button> @change="toggleSelfRecipient"
</div> />
<div class="recipient-list"> </div>
<t-checkbox-group <t-image
v-if="filteredList.length" :src="buildUserAvatar(selfUser)"
v-model="selectedUserIdList" :alt="getRecipientName(selfUser)"
class="recipient-options" fit="cover"
> class="avatar"
<t-checkbox />
v-for="item in filteredList" </div>
:key="item.userId" </template>
:value="item.userId" </t-cell>
class="recipient-option" <t-cell
borderless class="item"
> v-for="item in stores"
<div class="recipient-info"> :key="item.value"
<span class="recipient-name" v-text="getRecipientName(item)" /> :title="item.label"
<span class="recipient-phone" v-text="item.user?.phoneNo || item.userId" /> :description="getStoreSelectionDescription(item.value)"
</div> arrow
</t-checkbox> @click="openStore(item.value)"
</t-checkbox-group> >
<t-empty <template #left-icon>
v-else <div @click.stop>
:description="loading ? '正在加载用户' : '暂无匹配用户'" <t-checkbox
/> class="checkbox"
</div> icon="rectangle"
:model-value="isStoreSelected(item.value) || isStoreIndeterminate(item.value)"
:indeterminate="isStoreIndeterminate(item.value)"
borderless
@change="value => toggleStoreSelection(item.value, value === true)"
/>
</div>
</template>
</t-cell>
</t-cell-group>
<t-empty
v-else
:description="loading ? '正在加载门店用户' : '暂无可选择门店'"
/>
</div>
<div v-else key="user-list" class="recipient-picker user">
<div class="header">
<span v-text="selectedStore?.label || '选择用户'" />
<span v-text="getStoreSelectionDescription(pickerStoreId)" />
</div>
<div class="search-wrapper">
<t-input
v-model.trim="keyword"
class="search"
borderless
size="small"
clearable
placeholder="搜索编码、姓名(拼音)、或手机号"
/>
</div>
<t-cell-group v-if="filteredUserList.length">
<t-cell
v-for="item in filteredUserList"
:key="item.userId"
class="item recipient-option"
:title="getRecipientName(item)"
:description="item.user?.phoneNo || item.userId"
@click="toggleUserSelection(item.userId)"
>
<template #left-icon>
<div class="leading">
<div @click.stop>
<t-checkbox
class="checkbox"
:model-value="isUserSelected(item.userId)"
borderless
@change="value => toggleUserSelection(item.userId, value === true)"
/>
</div>
<t-image
:src="buildUserAvatar(item)"
:alt="getRecipientName(item)"
fit="cover"
class="avatar"
/>
</div>
</template>
</t-cell>
</t-cell-group>
<t-empty
v-else
:description="loading ? '正在加载用户' : '暂无匹配用户'"
/>
</div>
</transition>
</div> </div>
</t-popup> </bottom-popup>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { BottomPopup } from "timi-tdesign-mobile";
import { UserAPI } from "timi-web-api";
import type { ManagedUser } from "@/types/ManagedUser"; import type { ManagedUser } from "@/types/ManagedUser";
import { buildFallbackAvatar } from "@/utils/avatar";
export type NotifyRecipientScopeOption = {
label: string;
value: string;
};
defineOptions({
name: "NotifyRecipientPicker"
});
const props = withDefaults(defineProps<{ const props = withDefaults(defineProps<{
modelValue: string[]; modelValue: string[];
visible: boolean;
users: ManagedUser[]; users: ManagedUser[];
stores: NotifyRecipientScopeOption[];
selfUser?: ManagedUser; selfUser?: ManagedUser;
loading?: boolean; loading?: boolean;
}>(), { }>(), {
@@ -76,46 +156,42 @@ const props = withDefaults(defineProps<{
const emit = defineEmits<{ const emit = defineEmits<{
"update:modelValue": [value: string[]]; "update:modelValue": [value: string[]];
"update:visible": [value: boolean];
}>(); }>();
const visible = ref(false); const popupVisible = computed({
const keyword = ref(""); get: () => props.visible,
set: value => emit("update:visible", value)
});
const draftSelectedUserIdList = ref<string[]>([]); const draftSelectedUserIdList = ref<string[]>([]);
const pickerStoreId = ref("");
const keyword = ref("");
const pageDirection = ref<"forward" | "backward">("forward");
const selectedUserIdList = computed({ const pageTransitionName = computed(() => `page-slide-${pageDirection.value}`);
get: () => props.modelValue, const selectedStore = computed(() => props.stores.find(item => item.value === pickerStoreId.value));
set: value => emit("update:modelValue", value) const filteredUserList = computed(() => {
});
const availableList = computed(() => {
const selfUserId = props.selfUser?.userId;
const userList = selfUserId
? props.users.filter(item => item.userId !== selfUserId)
: props.users;
return props.selfUser ? [props.selfUser, ...userList] : userList;
});
const filteredList = computed(() => {
const normalizedKeyword = keyword.value.toLowerCase(); const normalizedKeyword = keyword.value.toLowerCase();
if (!normalizedKeyword) { return props.users
return availableList.value; .filter(item => item.storeId === pickerStoreId.value)
.filter(item => {
const user = item.user;
return !normalizedKeyword || [item.userId, user?.name, user?.nick, user?.phoneNo]
.some(value => Boolean(value?.toLowerCase().includes(normalizedKeyword)));
});
});
const storeUserMap = computed(() => {
const map = new Map<string, ManagedUser[]>();
for (const item of props.users) {
if (!item.storeId || !item.userId) {
continue;
}
const userList = map.get(item.storeId) || [];
userList.push(item);
map.set(item.storeId, userList);
} }
return map;
return availableList.value.filter(item => {
const user = item.user;
return [item.userId, user?.name, user?.nick, user?.phoneNo]
.some(value => Boolean(value?.toLowerCase().includes(normalizedKeyword)));
});
}); });
const filteredIdList = computed(() => filteredList.value
.map(item => item.userId)
.filter((item): item is string => Boolean(item)));
const allFilteredSelected = computed(() => 0 < filteredIdList.value.length
&& filteredIdList.value.every(id => selectedUserIdList.value.includes(id)));
const selectedText = computed(() => selectedUserIdList.value.length
? `已选择 ${selectedUserIdList.value.length} 位用户`
: "请选择接收用户");
function getRecipientName(item: ManagedUser): string { function getRecipientName(item: ManagedUser): string {
if (item.userId === props.selfUser?.userId) { if (item.userId === props.selfUser?.userId) {
@@ -124,127 +200,253 @@ function getRecipientName(item: ManagedUser): string {
return item.user?.nick || item.user?.name || item.userId || "未命名用户"; return item.user?.nick || item.user?.name || item.userId || "未命名用户";
} }
function openPicker(): void { function buildUserAvatar(item: ManagedUser): string {
draftSelectedUserIdList.value = [...selectedUserIdList.value]; const name = item.user?.nick || item.user?.name || item.userId || "用户";
keyword.value = ""; return UserAPI.getAvatarURL(item.user) || buildFallbackAvatar(name, "#0f766e");
visible.value = true;
} }
function cancelPicker(): void { function isUserSelected(userId?: string): boolean {
selectedUserIdList.value = [...draftSelectedUserIdList.value]; return Boolean(userId && draftSelectedUserIdList.value.includes(userId));
visible.value = false;
} }
function confirmPicker(): void { function toggleUserSelection(userId?: string, checked?: boolean): void {
draftSelectedUserIdList.value = [...selectedUserIdList.value]; if (!userId) {
visible.value = false;
}
function toggleSelectAll(): void {
const filteredIdSet = new Set(filteredIdList.value);
if (allFilteredSelected.value) {
selectedUserIdList.value = selectedUserIdList.value.filter(id => !filteredIdSet.has(id));
return; return;
} }
const selectedUserIdSet = new Set(draftSelectedUserIdList.value);
selectedUserIdList.value = Array.from(new Set([ if (checked === undefined ? !selectedUserIdSet.has(userId) : checked) {
...selectedUserIdList.value, selectedUserIdSet.add(userId);
...filteredIdList.value } else {
])); selectedUserIdSet.delete(userId);
}
draftSelectedUserIdList.value = Array.from(selectedUserIdSet);
} }
function isSelfRecipientSelected(): boolean {
const userId = props.selfUser?.userId;
return Boolean(userId && draftSelectedUserIdList.value.includes(userId));
}
function toggleSelfRecipient(value: boolean): void {
const userId = props.selfUser?.userId;
if (!userId) {
return;
}
const selectedUserIdSet = new Set(draftSelectedUserIdList.value);
if (value) {
selectedUserIdSet.add(userId);
} else {
selectedUserIdSet.delete(userId);
}
draftSelectedUserIdList.value = Array.from(selectedUserIdSet);
}
function isStoreSelected(storeId: string): boolean {
const userList = storeUserMap.value.get(storeId) || [];
const selectedUserIdSet = new Set(draftSelectedUserIdList.value);
return 0 < userList.length
&& userList.every(item => Boolean(item.userId && selectedUserIdSet.has(item.userId)));
}
function isStoreIndeterminate(storeId: string): boolean {
const userList = storeUserMap.value.get(storeId) || [];
const selectedUserIdSet = new Set(draftSelectedUserIdList.value);
const selectedCount = userList.filter(item => Boolean(item.userId && selectedUserIdSet.has(item.userId))).length;
return 0 < selectedCount && selectedCount < userList.length;
}
function getStoreSelectionDescription(storeId: string): string {
const userList = storeUserMap.value.get(storeId) || [];
const selectedUserIdSet = new Set(draftSelectedUserIdList.value);
const selectedCount = userList.filter(item => Boolean(item.userId && selectedUserIdSet.has(item.userId))).length;
return userList.length ? `已选择 ${selectedCount}/${userList.length} 位用户` : "暂无可选择用户";
}
function openStore(storeId: string): void {
pageDirection.value = "forward";
pickerStoreId.value = storeId;
keyword.value = "";
}
function toggleStoreSelection(storeId: string, checked: boolean): void {
const selectedUserIdSet = new Set(draftSelectedUserIdList.value);
for (const item of storeUserMap.value.get(storeId) || []) {
if (!item.userId) {
continue;
}
if (checked) {
selectedUserIdSet.add(item.userId);
} else {
selectedUserIdSet.delete(item.userId);
}
}
draftSelectedUserIdList.value = Array.from(selectedUserIdSet);
}
function handleLeftButton(): void {
if (pickerStoreId.value) {
pageDirection.value = "backward";
pickerStoreId.value = "";
keyword.value = "";
return;
}
popupVisible.value = false;
}
function confirmSelection(): void {
emit("update:modelValue", Array.from(new Set(draftSelectedUserIdList.value)));
pickerStoreId.value = "";
keyword.value = "";
popupVisible.value = false;
}
watch(() => props.visible, (value) => {
if (!value) {
return;
}
draftSelectedUserIdList.value = [...props.modelValue];
pageDirection.value = "forward";
pickerStoreId.value = "";
keyword.value = "";
});
</script> </script>
<style scoped lang="less"> <style scoped lang="less">
.selected-text {
color: #52607a;
width: 100%;
overflow: hidden;
text-align: right;
text-overflow: ellipsis;
white-space: nowrap;
}
.recipient-picker { .recipient-picker {
display: flex; display: flex;
height: 80vh; height: 100%;
padding-top: 1rem; background: white;
background: var(--td-bg-color-container);
flex-direction: column; flex-direction: column;
.picker-header { .summary {
padding: 0 1rem; padding: .5rem 1rem;
display: grid;
grid-template-columns: 4rem 1fr 4rem;
align-items: center;
h3 {
margin: 0;
text-align: center;
}
}
.search-wrapper {
padding: 1rem 0 .5rem;
box-shadow: 0 4px 4px rgba(0, 0, 0, .05);
.search {
margin: 0 1rem;
border: 1px solid var(--td-component-border);
border-radius: .875rem;
}
}
.selection-actions {
padding: .25rem 1rem;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
.selection-count { span {
color: #64748b; color: #64748b;
font-size: .8125rem; font-size: .8125rem;
} }
} }
.recipient-list { .item {
flex: 1; padding: .25rem 1rem;
overflow: auto;
min-height: 0;
padding: 0 1rem env(safe-area-inset-bottom, 0);
.recipient-options { :deep(.t-cell__left) {
display: grid; display: flex;
align-items: center;
}
.recipient-option { .checkbox {
padding: .75rem 0; padding: .5rem;
border-bottom: 1px solid var(--td-component-border);
.recipient-info { :deep(.t-checkbox__icon--left) {
gap: .25rem; margin-right: 0;
display: flex; }
flex-direction: column; }
}
.recipient-name { .leading {
font-size: .9375rem; gap: .5rem;
} display: flex;
align-items: center;
}
.recipient-phone { .avatar {
color: #64748b; width: 2.75rem;
font-size: .75rem; height: 2.75rem;
} overflow: hidden;
}
:deep(.avatar .t-image__img) {
width: 100%;
height: 100%;
}
}
&.store {
.list {
flex: 1;
overflow: auto;
min-height: 0;
}
}
&.user {
.header {
color: #64748b;
font-size: .8125rem;
text-align: center;
}
.search-wrapper {
padding: 1rem 0 .5rem;
box-shadow: 0 4px 4px rgba(0, 0, 0, .05);
.search {
--td-input-vertical-padding: .5rem 1rem;
margin: 0 1rem;
border: 1px solid var(--td-component-border);
border-radius: .875rem;
} }
} }
} }
} }
:global(html.theme-dark) {
.selected-text {
color: #94a3b8;
}
.selection-count, .page-transition {
.recipient-phone { width: 100%;
height: 100%;
overflow: hidden;
position: relative;
}
.page-slide-forward-enter-active,
.page-slide-forward-leave-active,
.page-slide-backward-enter-active,
.page-slide-backward-leave-active {
top: 0;
left: 0;
width: 100%;
height: 100%;
position: absolute;
transition: transform 320ms var(--tui-bezier), opacity 320ms var(--tui-bezier);
backface-visibility: hidden;
}
.page-slide-forward-enter-active {
z-index: 1;
}
.page-slide-forward-enter-from {
transform: translate3d(100%, 0, 0);
}
.page-slide-forward-leave-to {
transform: translate3d(-25%, 0, 0);
opacity: .92;
}
.page-slide-backward-enter-from {
transform: translate3d(-25%, 0, 0);
opacity: .92;
}
.page-slide-backward-leave-active {
z-index: 1;
}
.page-slide-backward-leave-to {
transform: translate3d(100%, 0, 0);
}
:global(html.theme-dark) {
.summary span,
.header,
.user .item :deep(.t-cell__description) {
color: #94a3b8; color: #94a3b8;
} }
} }
@@ -1,98 +0,0 @@
<template>
<t-form-item label="用户范围" arrow @click="openPicker">
<div class="scope-text" v-text="selectedLabel" />
</t-form-item>
<t-popup v-model="visible" placement="bottom" destroy-on-close>
<div class="scope-picker">
<header class="picker-header">
<t-button variant="text" size="small" @click="cancelPicker">取消</t-button>
<h3>选择用户范围</h3>
<t-button theme="primary" variant="text" size="small" @click="confirmPicker">确定</t-button>
</header>
<t-radio-group v-model="draftValue" class="scope-options">
<t-radio
v-for="item in options"
:key="item.value"
:block="false"
borderless
:value="item.value"
>
<span v-text="item.label" />
</t-radio>
</t-radio-group>
</div>
</t-popup>
</template>
<script setup lang="ts">
export type NotifyRecipientScopeOption = {
label: string;
value: string;
};
const props = defineProps<{
modelValue: string;
options: NotifyRecipientScopeOption[];
}>();
const emit = defineEmits<{
"update:modelValue": [value: string];
}>();
const visible = ref(false);
const draftValue = ref(props.modelValue);
const selectedLabel = computed(() => props.options.find(item => item.value === props.modelValue)?.label || "请选择用户范围");
function openPicker(): void {
draftValue.value = props.modelValue;
visible.value = true;
}
function cancelPicker(): void {
visible.value = false;
}
function confirmPicker(): void {
emit("update:modelValue", draftValue.value);
visible.value = false;
}
</script>
<style scoped lang="less">
.scope-text {
color: #52607a;
width: 100%;
overflow: hidden;
text-align: right;
text-overflow: ellipsis;
white-space: nowrap;
}
.scope-picker {
padding: 1rem 1rem env(safe-area-inset-bottom, 0);
background: var(--td-bg-color-container);
.picker-header {
display: grid;
grid-template-columns: 4rem 1fr 4rem;
align-items: center;
h3 {
margin: 0;
text-align: center;
}
}
.scope-options {
gap: .5rem;
margin-top: 1rem;
display: grid;
}
}
:global(html.theme-dark) {
.scope-text {
color: #94a3b8;
}
}
</style>
+1
View File
@@ -99,6 +99,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { BottomPopup } from "timi-tdesign-mobile";
import { Message } from "@/utils/message"; import { Message } from "@/utils/message";
import { useUserStore } from "timi-web-api"; import { useUserStore } from "timi-web-api";
import { useCurrentStoreStore } from "@/store/currentStoreStore"; import { useCurrentStoreStore } from "@/store/currentStoreStore";
@@ -35,7 +35,6 @@
</div> </div>
<bottom-popup <bottom-popup
v-model="tierEditorVisible" v-model="tierEditorVisible"
placement="bottom"
:title="tierEditorTitle" :title="tierEditorTitle"
@left-button-click="closeTierEditor" @left-button-click="closeTierEditor"
@right-button-click="confirmTierEditor" @right-button-click="confirmTierEditor"
@@ -91,6 +90,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { BottomPopup } from "timi-tdesign-mobile";
import type { EventValueUnit } from "@/types/EventItem"; import type { EventValueUnit } from "@/types/EventItem";
import type { PointAction, PointRuleTierForm } from "@/types/Point"; import type { PointAction, PointRuleTierForm } from "@/types/Point";
import { formatTierBoundaryValue, getTierMinText, getTierMinValue, isLastTier, resolveNonNegativeInteger, resolveTierBoundaryValue } from "@/utils/pointRule"; import { formatTierBoundaryValue, getTierMinText, getTierMinValue, isLastTier, resolveNonNegativeInteger, resolveTierBoundaryValue } from "@/utils/pointRule";