feat(project): sync all pending tenant ui changes
This commit is contained in:
330
apps/web-antd/src/views/shared/components/CopyToStoresModal.vue
Normal file
330
apps/web-antd/src/views/shared/components/CopyToStoresModal.vue
Normal file
@@ -0,0 +1,330 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 文件职责:复制配置到其他门店弹窗。
|
||||
* 1. 展示可选目标门店列表。
|
||||
* 2. 处理全选/单选交互并向父级抛事件。
|
||||
* 3. 仅做 UI 交互,不执行复制请求。
|
||||
*/
|
||||
import type { StoreListItemDto } from '#/api/store';
|
||||
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { Button, Empty, Modal } from 'ant-design-vue';
|
||||
|
||||
interface Props {
|
||||
confirmText?: string;
|
||||
copyCandidates: StoreListItemDto[];
|
||||
isAllChecked: boolean;
|
||||
isIndeterminate: boolean;
|
||||
isSubmitting: boolean;
|
||||
open: boolean;
|
||||
selectedStoreName: string;
|
||||
targetStoreIds: string[];
|
||||
title?: string;
|
||||
warningText?: string;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
confirmText: '确认复制',
|
||||
title: '复制配置到其他门店',
|
||||
warningText: '将覆盖目标门店的现有设置,请谨慎操作',
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'checkAll', checked: boolean): void;
|
||||
(event: 'submit'): void;
|
||||
(event: 'toggleStore', payload: { checked: boolean; storeId: string }): void;
|
||||
(event: 'update:open', value: boolean): void;
|
||||
}>();
|
||||
|
||||
const selectedStoreIdSet = computed(() => new Set(props.targetStoreIds));
|
||||
const hasCandidates = computed(() => props.copyCandidates.length > 0);
|
||||
|
||||
/** 判断目标门店是否被选中。 */
|
||||
function isStoreChecked(storeId: string) {
|
||||
return selectedStoreIdSet.value.has(storeId);
|
||||
}
|
||||
|
||||
/** 切换单个门店选中态并通知父级。 */
|
||||
function toggleStore(storeId: string) {
|
||||
emit('toggleStore', {
|
||||
storeId,
|
||||
checked: !isStoreChecked(storeId),
|
||||
});
|
||||
}
|
||||
|
||||
/** 切换全选状态并通知父级。 */
|
||||
function toggleAll() {
|
||||
emit('checkAll', !props.isAllChecked);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal
|
||||
:open="props.open"
|
||||
:title="props.title"
|
||||
:width="650"
|
||||
:footer="null"
|
||||
:mask-closable="true"
|
||||
wrap-class-name="store-copy-modal-wrap"
|
||||
@update:open="(value) => emit('update:open', value)"
|
||||
>
|
||||
<div class="store-copy-modal-content">
|
||||
<div class="store-copy-modal-source">
|
||||
当前门店:{{ props.selectedStoreName || '--' }}
|
||||
</div>
|
||||
<div class="store-copy-modal-warning">
|
||||
<span class="store-copy-modal-warning-icon">!</span>
|
||||
<span>{{ props.warningText }}</span>
|
||||
</div>
|
||||
|
||||
<template v-if="hasCandidates">
|
||||
<div class="store-copy-all-row" @click="toggleAll">
|
||||
<span
|
||||
class="store-copy-check"
|
||||
:class="{
|
||||
checked: props.isAllChecked,
|
||||
indeterminate: props.isIndeterminate && !props.isAllChecked,
|
||||
}"
|
||||
>
|
||||
<span class="store-copy-check-mark"></span>
|
||||
</span>
|
||||
<span>全选</span>
|
||||
</div>
|
||||
|
||||
<div class="store-copy-list">
|
||||
<div
|
||||
v-for="store in props.copyCandidates"
|
||||
:key="store.id"
|
||||
class="store-copy-item"
|
||||
@click="toggleStore(store.id)"
|
||||
>
|
||||
<span
|
||||
class="store-copy-check"
|
||||
:class="{ checked: isStoreChecked(store.id) }"
|
||||
>
|
||||
<span class="store-copy-check-mark"></span>
|
||||
</span>
|
||||
<div class="store-copy-info">
|
||||
<div class="store-copy-name">{{ store.name }}</div>
|
||||
<div class="store-copy-address">{{ store.address || '--' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-else class="store-copy-empty">
|
||||
<Empty description="暂无可复制的门店" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="store-copy-modal-footer">
|
||||
<Button @click="emit('update:open', false)">取消</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
:loading="props.isSubmitting"
|
||||
:disabled="props.targetStoreIds.length === 0"
|
||||
@click="emit('submit')"
|
||||
>
|
||||
{{ props.confirmText }}
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
<style lang="less">
|
||||
/* 文件职责:复制配置弹窗(Teleport)全局样式。 */
|
||||
.store-copy-modal-wrap {
|
||||
.ant-modal {
|
||||
width: 650px !important;
|
||||
max-width: calc(100vw - 32px);
|
||||
}
|
||||
|
||||
.ant-modal-content {
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
border-radius: 14px;
|
||||
box-shadow: 0 10px 30px rgb(0 0 0 / 12%);
|
||||
}
|
||||
|
||||
.ant-modal-header {
|
||||
margin-bottom: 0;
|
||||
padding: 20px 28px 14px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.ant-modal-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #1f2329;
|
||||
}
|
||||
|
||||
.ant-modal-close {
|
||||
top: 18px;
|
||||
right: 20px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 8px;
|
||||
color: #8f959e;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.ant-modal-close:hover {
|
||||
color: #4e5969;
|
||||
background: #f2f3f5;
|
||||
}
|
||||
|
||||
.ant-modal-body {
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.store-copy-modal-content {
|
||||
padding: 18px 28px 0;
|
||||
}
|
||||
|
||||
.store-copy-modal-source {
|
||||
margin-bottom: 10px;
|
||||
font-size: 13px;
|
||||
color: #4e5969;
|
||||
}
|
||||
|
||||
.store-copy-modal-warning {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
margin-bottom: 18px;
|
||||
padding: 10px 14px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: #c48b26;
|
||||
background: #fffbe6;
|
||||
border: 1px solid #f7e4a1;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.store-copy-modal-warning-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
background: #f5b034;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.store-copy-all-row {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
padding: 0 0 12px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: #1f2329;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.store-copy-list {
|
||||
max-height: 340px;
|
||||
padding-top: 12px;
|
||||
overflow-y: auto;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.store-copy-item {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 10px;
|
||||
padding: 12px 12px 10px;
|
||||
background: #fafbfc;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s ease;
|
||||
}
|
||||
|
||||
.store-copy-item:hover {
|
||||
background: #f3f6fb;
|
||||
}
|
||||
|
||||
.store-copy-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.store-copy-name {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
color: #1f2329;
|
||||
}
|
||||
|
||||
.store-copy-address {
|
||||
margin-top: 2px;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
color: #86909c;
|
||||
}
|
||||
|
||||
.store-copy-check {
|
||||
display: inline-flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
margin-top: 2px;
|
||||
background: #fff;
|
||||
border: 1px solid #d9dde3;
|
||||
border-radius: 5px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.store-copy-check-mark {
|
||||
display: none;
|
||||
width: 6px;
|
||||
height: 10px;
|
||||
border: 2px solid #fff;
|
||||
border-top: 0;
|
||||
border-left: 0;
|
||||
transform: rotate(45deg) translate(-1px, -1px);
|
||||
}
|
||||
|
||||
.store-copy-check.checked {
|
||||
background: #1677ff;
|
||||
border-color: #1677ff;
|
||||
}
|
||||
|
||||
.store-copy-check.checked .store-copy-check-mark {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.store-copy-check.indeterminate {
|
||||
background: #1677ff;
|
||||
border-color: #1677ff;
|
||||
}
|
||||
|
||||
.store-copy-check.indeterminate .store-copy-check-mark {
|
||||
display: block;
|
||||
width: 10px;
|
||||
height: 2px;
|
||||
border: 0;
|
||||
background: #fff;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.store-copy-empty {
|
||||
padding: 20px 0;
|
||||
}
|
||||
|
||||
.store-copy-modal-footer {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
justify-content: flex-end;
|
||||
padding: 14px 28px 18px;
|
||||
margin-top: 10px;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
}
|
||||
</style>
|
||||
117
apps/web-antd/src/views/shared/components/StoreScopeToolbar.vue
Normal file
117
apps/web-antd/src/views/shared/components/StoreScopeToolbar.vue
Normal file
@@ -0,0 +1,117 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 文件职责:门店维度工具栏。
|
||||
* 1. 提供门店下拉选择。
|
||||
* 2. 提供“复制到其他门店”入口按钮。
|
||||
* 3. 仅负责展示和事件透出,不承载业务请求。
|
||||
*/
|
||||
import { Button, Card, Select } from 'ant-design-vue';
|
||||
|
||||
interface StoreOption {
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
copyButtonText?: string;
|
||||
copyPosition?: 'left' | 'right';
|
||||
copyDisabled?: boolean;
|
||||
isStoreLoading: boolean;
|
||||
showCopyButton?: boolean;
|
||||
selectedStoreId: string;
|
||||
storeOptions: StoreOption[];
|
||||
storePlaceholder?: string;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
copyButtonText: '复制到其他门店',
|
||||
copyPosition: 'right',
|
||||
copyDisabled: false,
|
||||
showCopyButton: true,
|
||||
storePlaceholder: '请选择门店',
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'copy'): void;
|
||||
(event: 'update:selectedStoreId', value: string): void;
|
||||
}>();
|
||||
|
||||
/** 透传门店选择事件,统一收敛为字符串 ID。 */
|
||||
function handleStoreChange(value: unknown) {
|
||||
if (typeof value === 'number' || typeof value === 'string') {
|
||||
emit('update:selectedStoreId', String(value));
|
||||
return;
|
||||
}
|
||||
emit('update:selectedStoreId', '');
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Card :bordered="false" class="store-scope-toolbar-card">
|
||||
<div class="store-scope-toolbar">
|
||||
<Select
|
||||
:value="props.selectedStoreId"
|
||||
class="store-scope-selector"
|
||||
:placeholder="props.storePlaceholder"
|
||||
:loading="props.isStoreLoading"
|
||||
:options="props.storeOptions"
|
||||
:disabled="props.isStoreLoading || props.storeOptions.length === 0"
|
||||
@update:value="(value) => handleStoreChange(value)"
|
||||
/>
|
||||
<Button
|
||||
v-if="props.showCopyButton && props.copyPosition === 'left'"
|
||||
:disabled="props.copyDisabled"
|
||||
@click="emit('copy')"
|
||||
>
|
||||
{{ props.copyButtonText }}
|
||||
</Button>
|
||||
<div class="store-scope-spacer"></div>
|
||||
<Button
|
||||
v-if="props.showCopyButton && props.copyPosition === 'right'"
|
||||
:disabled="props.copyDisabled"
|
||||
@click="emit('copy')"
|
||||
>
|
||||
{{ props.copyButtonText }}
|
||||
</Button>
|
||||
<slot name="actions"></slot>
|
||||
</div>
|
||||
</Card>
|
||||
</template>
|
||||
|
||||
<style scoped lang="less">
|
||||
/* 文件职责:门店维度工具栏样式。 */
|
||||
.store-scope-toolbar-card {
|
||||
:deep(.ant-card-body) {
|
||||
padding: 14px 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.store-scope-toolbar {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.store-scope-selector {
|
||||
width: 280px;
|
||||
}
|
||||
|
||||
.store-scope-spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.store-scope-selector {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.store-scope-toolbar {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.store-scope-spacer {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user