feat(project): sync all pending tenant ui changes

This commit is contained in:
2026-02-20 16:50:20 +08:00
parent 937c9b4334
commit 788491ad3d
34 changed files with 7527 additions and 95 deletions

View 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>

View 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>