refactor file tree
This commit is contained in:
@@ -0,0 +1,690 @@
|
||||
<template>
|
||||
<div v-can="permissionCodes.stat.read" class="customer-data-charts-page">
|
||||
<div class="header">
|
||||
<div class="selector bg-white" :class="{ 'selected' : selectedCustomer }">
|
||||
<div class="line buttons">
|
||||
<t-button
|
||||
v-if="selectedCustomer"
|
||||
theme="primary"
|
||||
@click="clearCustomerSelection"
|
||||
>清除</t-button>
|
||||
<t-button class="customer" block theme="primary" variant="outline" @click="selectorVisible = true">
|
||||
<template #icon>
|
||||
<t-icon name="user-search" />
|
||||
</template>
|
||||
<span v-text="selectedCustomer ? selectedCustomer.name : '选择客户'" />
|
||||
</t-button>
|
||||
<t-button theme="primary" @click="toggleScanner">
|
||||
<template #icon>
|
||||
<t-icon name="scan" />
|
||||
</template>
|
||||
<span>扫码</span>
|
||||
</t-button>
|
||||
</div>
|
||||
<div v-if="scannerVisible" class="line scanner-wrapper">
|
||||
<code-scanner
|
||||
visible
|
||||
:status-text="scanStatus"
|
||||
active-text="正在识别客户编码,请将二维码移入取景框"
|
||||
@decode="handleDecodedText"
|
||||
@status-change="handleScanStatusChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<section v-if="selectedCustomer" class="summary">
|
||||
<div v-for="item in summaryList" :key="item.label" class="summary-item">
|
||||
<span v-text="item.label" />
|
||||
<strong v-text="item.value" />
|
||||
</div>
|
||||
</section>
|
||||
<event-record-calendar
|
||||
v-if="selectedCustomer"
|
||||
v-model="monthCursor"
|
||||
v-model:selected-event-id-list="selectedEventIdList"
|
||||
class="calendar-section"
|
||||
:event-list="eventList"
|
||||
:customer-name="selectedCustomer.name"
|
||||
:loading="calendarLoading"
|
||||
:records="monthRecords"
|
||||
/>
|
||||
<section
|
||||
v-for="item in chartList"
|
||||
:key="item.id"
|
||||
class="chart-section"
|
||||
>
|
||||
<h2 v-text="item.title" />
|
||||
<div :ref="element => setChartRef(item.id, element)" class="chart" />
|
||||
</section>
|
||||
<customer-selector
|
||||
v-model="selectedCustomerId"
|
||||
v-model:visible="selectorVisible"
|
||||
@change="handleCustomerSelected"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type {ECharts, EChartsOption} from "echarts";
|
||||
import {BarChart, LineChart} from "echarts/charts";
|
||||
import {GridComponent, LegendComponent, TooltipComponent} from "echarts/components";
|
||||
import {init, use} from "echarts/core";
|
||||
import {CanvasRenderer} from "echarts/renderers";
|
||||
import {Time} from "timi-web";
|
||||
import CustomerAPI from "@/api/CustomerAPI";
|
||||
import EventAPI from "@/api/EventAPI";
|
||||
import EventRecordAPI from "@/api/EventRecordAPI";
|
||||
import type {Customer} from "@/types/Customer";
|
||||
import type {EventItem} from "@/types/EventItem";
|
||||
import type {EventRecord} from "@/types/EventRecord";
|
||||
import CodeScanner from "@/components/CodeScanner.vue";
|
||||
import CustomerSelector from "@/components/CustomerSelector.vue";
|
||||
import EventRecordCalendar from "@/components/EventRecordCalendar.vue";
|
||||
import {useSettingStore} from "@/store/settingStore";
|
||||
import {permissionCodes} from "@/utils/permission";
|
||||
|
||||
interface SummaryItem {
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
interface ChartProfile {
|
||||
id: string;
|
||||
title: string;
|
||||
option: EChartsOption;
|
||||
}
|
||||
|
||||
defineOptions({
|
||||
name: "CustomerDataCharts"
|
||||
});
|
||||
|
||||
use([
|
||||
BarChart,
|
||||
LineChart,
|
||||
GridComponent,
|
||||
LegendComponent,
|
||||
TooltipComponent,
|
||||
CanvasRenderer
|
||||
]);
|
||||
|
||||
const settingStore = useSettingStore();
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
const chartRefs = new Map<string, HTMLElement>();
|
||||
const chartInstances = new Map<string, ECharts>();
|
||||
const routeCustomerId = computed(() => String(route.params.id || ""));
|
||||
const routeCustomerCode = computed(() => String(route.query.customerCode || ""));
|
||||
const customerCode = ref(routeCustomerCode.value);
|
||||
const selectedCustomerId = ref(routeCustomerId.value);
|
||||
const selectorVisible = ref(false);
|
||||
const scannerVisible = ref(false);
|
||||
const scanStatus = ref("点击扫码开始识别。");
|
||||
const selectedCustomer = ref<Customer>();
|
||||
const eventList = ref<EventItem[]>([]);
|
||||
const selectedEventIdList = ref<string[]>([]);
|
||||
const monthCursor = ref(getMonthStart(new Date()));
|
||||
const monthRecords = ref<EventRecord[]>([]);
|
||||
const calendarLoading = ref(false);
|
||||
let latestDecodedText = "";
|
||||
|
||||
const eventNameMap = computed(() => {
|
||||
const map = new Map<string, string>();
|
||||
for (const item of eventList.value) {
|
||||
if (item.id) {
|
||||
map.set(item.id, item.name || "未命名事件");
|
||||
}
|
||||
}
|
||||
return map;
|
||||
});
|
||||
const selectedEventIdSet = computed(() => new Set(selectedEventIdList.value));
|
||||
const filteredMonthRecords = computed(() => {
|
||||
if (!selectedEventIdList.value.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return monthRecords.value.filter(item => {
|
||||
if (!item.eventId || !selectedEventIdSet.value.has(item.eventId)) {
|
||||
return false;
|
||||
}
|
||||
const recordDate = Time.toDateObject(item.registeredAt);
|
||||
return !!recordDate && recordDate.getFullYear() === monthCursor.value.getFullYear() && recordDate.getMonth() === monthCursor.value.getMonth();
|
||||
});
|
||||
});
|
||||
const monthDateTextList = computed(() => buildMonthDateTextList(monthCursor.value));
|
||||
const summaryList = computed<SummaryItem[]>(() => [
|
||||
{
|
||||
label: "本月登记",
|
||||
value: `${filteredMonthRecords.value.length} 次`
|
||||
},
|
||||
{
|
||||
label: "转介绍",
|
||||
value: `${selectedCustomer.value?.invitedCount || 0} 人`
|
||||
},
|
||||
{
|
||||
label: "最近登记",
|
||||
value: getRecordEventName(filteredMonthRecords.value[0]) || "暂无"
|
||||
}
|
||||
]);
|
||||
const chartList = computed<ChartProfile[]>(() => {
|
||||
if (!selectedCustomer.value) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
id: "customer-record-trend",
|
||||
title: "客户登记趋势",
|
||||
option: buildRecordTrendOption()
|
||||
},
|
||||
{
|
||||
id: "customer-event-structure",
|
||||
title: "登记事件结构",
|
||||
option: buildBehaviorStructureOption()
|
||||
},
|
||||
{
|
||||
id: "customer-relation",
|
||||
title: "客户关系数据",
|
||||
option: buildRelationOption()
|
||||
}
|
||||
];
|
||||
});
|
||||
watch(chartList, async () => {
|
||||
await nextTick();
|
||||
renderCharts();
|
||||
});
|
||||
|
||||
watch(routeCustomerCode, (value) => {
|
||||
if (value) {
|
||||
customerCode.value = value;
|
||||
}
|
||||
});
|
||||
|
||||
watch(routeCustomerId, async (value) => {
|
||||
if (value) {
|
||||
await loadCustomerById(value);
|
||||
}
|
||||
});
|
||||
|
||||
watch(customerCode, async (value) => {
|
||||
await loadCustomer(value);
|
||||
});
|
||||
|
||||
watch([selectedCustomer, monthCursor], async () => {
|
||||
await loadMonthRecords();
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
await loadActions();
|
||||
if (routeCustomerId.value) {
|
||||
await loadCustomerById(routeCustomerId.value);
|
||||
} else if (customerCode.value) {
|
||||
await loadCustomer(customerCode.value);
|
||||
}
|
||||
await nextTick();
|
||||
renderCharts();
|
||||
window.addEventListener("resize", resizeCharts);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener("resize", resizeCharts);
|
||||
chartInstances.forEach(item => item.dispose());
|
||||
chartInstances.clear();
|
||||
});
|
||||
|
||||
function setChartRef(id: string, element: Element | ComponentPublicInstance | null): void {
|
||||
if (element instanceof HTMLElement) {
|
||||
chartRefs.set(id, element);
|
||||
}
|
||||
}
|
||||
|
||||
function renderCharts(): void {
|
||||
for (const item of chartList.value) {
|
||||
const element = chartRefs.get(item.id);
|
||||
if (!element) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const chart = getChartInstance(item.id, element);
|
||||
chart.setOption(item.option, true);
|
||||
}
|
||||
}
|
||||
|
||||
function getChartInstance(id: string, element: HTMLElement): ECharts {
|
||||
const instance = chartInstances.get(id);
|
||||
if (instance) {
|
||||
return instance;
|
||||
}
|
||||
|
||||
const chart = init(element);
|
||||
chartInstances.set(id, chart);
|
||||
return chart;
|
||||
}
|
||||
|
||||
function resizeCharts(): void {
|
||||
chartInstances.forEach(item => item.resize());
|
||||
}
|
||||
|
||||
// ---------- 数据 ----------
|
||||
|
||||
async function loadActions(): Promise<void> {
|
||||
eventList.value = await EventAPI.listValid();
|
||||
selectedEventIdList.value = eventList.value.map(item => item.id).filter((item): item is string => Boolean(item));
|
||||
}
|
||||
|
||||
async function loadCustomer(code: string): Promise<void> {
|
||||
const normalizedCode = code.trim();
|
||||
if (!normalizedCode) {
|
||||
selectedCustomer.value = undefined;
|
||||
selectedCustomerId.value = "";
|
||||
monthRecords.value = [];
|
||||
return;
|
||||
}
|
||||
|
||||
const customer = await CustomerAPI.findByCode(normalizedCode);
|
||||
if (!customer) {
|
||||
selectedCustomer.value = undefined;
|
||||
selectedCustomerId.value = "";
|
||||
monthRecords.value = [];
|
||||
return;
|
||||
}
|
||||
|
||||
selectedCustomer.value = customer;
|
||||
selectedCustomerId.value = customer.id || "";
|
||||
}
|
||||
|
||||
async function loadCustomerById(id: string): Promise<void> {
|
||||
const normalizedId = id.trim();
|
||||
if (!normalizedId) {
|
||||
selectedCustomer.value = undefined;
|
||||
selectedCustomerId.value = "";
|
||||
monthRecords.value = [];
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
selectedCustomer.value = await CustomerAPI.detail(normalizedId);
|
||||
selectedCustomerId.value = selectedCustomer.value.id || "";
|
||||
} catch (err) {
|
||||
selectedCustomer.value = undefined;
|
||||
selectedCustomerId.value = "";
|
||||
monthRecords.value = [];
|
||||
console.warn("客户详情加载失败", err);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCustomerSelected(id: string): Promise<void> {
|
||||
await loadCustomerById(id);
|
||||
}
|
||||
|
||||
async function clearCustomerSelection(): Promise<void> {
|
||||
selectedCustomer.value = undefined;
|
||||
selectedCustomerId.value = "";
|
||||
customerCode.value = "";
|
||||
monthRecords.value = [];
|
||||
|
||||
if (route.name !== "CustomerDataCharts" || Object.keys(route.query).length) {
|
||||
await router.replace({name: "CustomerDataCharts"});
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMonthRecords(): Promise<void> {
|
||||
const customerId = selectedCustomer.value?.id;
|
||||
if (!customerId) {
|
||||
monthRecords.value = [];
|
||||
return;
|
||||
}
|
||||
|
||||
const beginDate = getCalendarBeginDate(monthCursor.value);
|
||||
const endDate = getDateEnd(addDays(beginDate, 41));
|
||||
calendarLoading.value = true;
|
||||
try {
|
||||
monthRecords.value = (await EventRecordAPI.list({
|
||||
index: 0,
|
||||
size: Number.MAX_SAFE_INTEGER,
|
||||
customerId,
|
||||
beginAt: Time.toTimestamp(beginDate),
|
||||
endAt: Time.toTimestamp(endDate)
|
||||
})).list;
|
||||
} finally {
|
||||
calendarLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 扫码 ----------
|
||||
|
||||
async function toggleScanner(): Promise<void> {
|
||||
scannerVisible.value = !scannerVisible.value;
|
||||
if (scannerVisible.value) {
|
||||
latestDecodedText = "";
|
||||
scanStatus.value = "正在启动扫码预览。";
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDecodedText(rawText: string): Promise<void> {
|
||||
const normalizedText = rawText.trim();
|
||||
if (!normalizedText || normalizedText === latestDecodedText) {
|
||||
return;
|
||||
}
|
||||
|
||||
latestDecodedText = normalizedText;
|
||||
await settingStore.playScanSuccessSound();
|
||||
customerCode.value = normalizedText;
|
||||
scanStatus.value = `已识别 ${normalizedText},正在加载客户档案。`;
|
||||
scannerVisible.value = false;
|
||||
}
|
||||
|
||||
function handleScanStatusChange(value: string): void {
|
||||
scanStatus.value = value;
|
||||
}
|
||||
|
||||
function getRecordDateText(record: EventRecord): string {
|
||||
const date = Time.toDateObject(record.registeredAt);
|
||||
return date ? Time.formatDate(date) : "";
|
||||
}
|
||||
|
||||
function getRecordEventName(record?: EventRecord): string {
|
||||
if (!record) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return record.event?.name || (record.eventId ? eventNameMap.value.get(record.eventId) : "") || "未知事件";
|
||||
}
|
||||
|
||||
// ---------- 图表 ----------
|
||||
|
||||
function buildBaseOption(): EChartsOption {
|
||||
return {
|
||||
color: ["#2563eb", "#0f766e", "#d97706", "#db2777", "#7c3aed"],
|
||||
grid: {
|
||||
top: 48,
|
||||
left: 12,
|
||||
right: 12,
|
||||
bottom: 12,
|
||||
containLabel: true
|
||||
},
|
||||
legend: {
|
||||
top: 0,
|
||||
icon: "roundRect",
|
||||
textStyle: {
|
||||
color: "#52607a"
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
trigger: "axis"
|
||||
},
|
||||
xAxis: {
|
||||
type: "category",
|
||||
data: monthDateTextList.value.map(item => item.slice(5)),
|
||||
axisTick: {
|
||||
show: false
|
||||
},
|
||||
axisLabel: {
|
||||
color: "#52607a"
|
||||
}
|
||||
},
|
||||
yAxis: {
|
||||
type: "value",
|
||||
axisLabel: {
|
||||
color: "#52607a"
|
||||
},
|
||||
splitLine: {
|
||||
lineStyle: {
|
||||
color: "rgba(148, 163, 184, .2)"
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function buildRecordTrendOption(): EChartsOption {
|
||||
return {
|
||||
...buildBaseOption(),
|
||||
series: [
|
||||
{
|
||||
name: "登记次数",
|
||||
type: "line",
|
||||
smooth: true,
|
||||
data: monthDateTextList.value.map(date => filteredMonthRecords.value.filter(item => getRecordDateText(item) === date).length)
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
function buildBehaviorStructureOption(): EChartsOption {
|
||||
return {
|
||||
...buildBaseOption(),
|
||||
xAxis: {
|
||||
type: "category",
|
||||
data: eventList.value.filter(item => item.id && selectedEventIdSet.value.has(item.id)).map(item => item.name || "未命名事件"),
|
||||
axisTick: {
|
||||
show: false
|
||||
},
|
||||
axisLabel: {
|
||||
color: "#52607a"
|
||||
}
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: "登记次数",
|
||||
type: "bar",
|
||||
data: eventList.value
|
||||
.filter(item => item.id && selectedEventIdSet.value.has(item.id))
|
||||
.map(item => ({
|
||||
value: filteredMonthRecords.value.filter(record => record.eventId === item.id).length,
|
||||
itemStyle: getEventColor(item) ? {
|
||||
color: getEventColor(item)
|
||||
} : undefined
|
||||
}))
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
function getEventColor(event: EventItem): string | undefined {
|
||||
return event.color || undefined;
|
||||
}
|
||||
|
||||
function buildRelationOption(): EChartsOption {
|
||||
return {
|
||||
...buildBaseOption(),
|
||||
xAxis: {
|
||||
type: "category",
|
||||
data: ["介绍人", "转介绍客户"],
|
||||
axisTick: {
|
||||
show: false
|
||||
},
|
||||
axisLabel: {
|
||||
color: "#52607a"
|
||||
}
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: "客户数",
|
||||
type: "bar",
|
||||
data: [selectedCustomer.value?.introducerCustomerId ? 1 : 0, selectedCustomer.value?.invitedCount || 0]
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
// ---------- 日期 ----------
|
||||
|
||||
function getMonthStart(date: Date): Date {
|
||||
return new Date(date.getFullYear(), date.getMonth(), 1, 0, 0, 0, 0);
|
||||
}
|
||||
|
||||
function getCalendarBeginDate(date: Date): Date {
|
||||
const monthStart = getMonthStart(date);
|
||||
return addDays(monthStart, -monthStart.getDay());
|
||||
}
|
||||
|
||||
function buildMonthDateTextList(date: Date): string[] {
|
||||
const monthStart = getMonthStart(date);
|
||||
const nextMonthStart = addMonths(monthStart, 1);
|
||||
const list: string[] = [];
|
||||
for (let item = monthStart; item < nextMonthStart; item = addDays(item, 1)) {
|
||||
list.push(Time.formatDate(item));
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
function addDays(date: Date, days: number): Date {
|
||||
const value = new Date(date);
|
||||
value.setDate(value.getDate() + days);
|
||||
return value;
|
||||
}
|
||||
|
||||
function addMonths(date: Date, months: number): Date {
|
||||
return new Date(date.getFullYear(), date.getMonth() + months, 1, 0, 0, 0, 0);
|
||||
}
|
||||
|
||||
function getDateEnd(date: Date): Date {
|
||||
return new Date(date.getFullYear(), date.getMonth(), date.getDate(), 23, 59, 59, 999);
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.customer-data-charts-page {
|
||||
gap: 1rem;
|
||||
padding: var(--app-nav-height) 0 1rem 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.header {
|
||||
top: var(--app-nav-height);
|
||||
z-index: 10;
|
||||
padding: 1rem 1rem .875rem 1rem;
|
||||
position: sticky;
|
||||
box-shadow: var(--td-shadow-1);
|
||||
background: #F5F5F8;
|
||||
|
||||
.selector {
|
||||
border: 1px solid rgba(148, 163, 184, .18);
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
border-radius: .35rem .35rem 1rem 1rem;
|
||||
flex-direction: column;
|
||||
|
||||
&.selected {
|
||||
border: 1px solid transparent;
|
||||
background: transparent;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.line {
|
||||
gap: .75rem;
|
||||
display: flex;
|
||||
|
||||
&.buttons {
|
||||
z-index: 1;
|
||||
|
||||
.customer {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&.scanner-wrapper {
|
||||
margin: 1rem .5rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.summary {
|
||||
gap: .75rem;
|
||||
margin: 0 1rem;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
|
||||
.summary-item {
|
||||
padding: .875rem .625rem;
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
background: rgba(255, 255, 255, .92);
|
||||
box-shadow: var(--td-shadow-1);
|
||||
align-items: center;
|
||||
border-radius: .75rem;
|
||||
flex-direction: column;
|
||||
|
||||
span {
|
||||
color: #52607a;
|
||||
font-size: .75rem;
|
||||
}
|
||||
|
||||
strong {
|
||||
margin-top: .375rem;
|
||||
color: #0f172a;
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.chart-section {
|
||||
margin: 0 1rem;
|
||||
padding: 1rem;
|
||||
background: rgba(255, 255, 255, .92);
|
||||
box-shadow: var(--td-shadow-1);
|
||||
border-radius: .75rem;
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
color: #0f172a;
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
}
|
||||
|
||||
.calendar-section {
|
||||
margin: 0 1rem;
|
||||
}
|
||||
|
||||
.chart-section {
|
||||
h2 {
|
||||
margin-bottom: .875rem;
|
||||
}
|
||||
|
||||
.chart {
|
||||
width: 100%;
|
||||
height: 16rem;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
:global(html.theme-dark) {
|
||||
.customer-data-charts-page {
|
||||
.selector {
|
||||
background: #10161f;
|
||||
}
|
||||
|
||||
.summary {
|
||||
.summary-item {
|
||||
background: rgba(15, 23, 42, .88);
|
||||
box-shadow: 0 .75rem 2rem rgba(2, 6, 23, .32);
|
||||
|
||||
span {
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
strong {
|
||||
color: #e2e8f0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.calendar-section,
|
||||
.chart-section {
|
||||
background: rgba(15, 23, 42, .88);
|
||||
box-shadow: 0 .75rem 2rem rgba(2, 6, 23, .32);
|
||||
|
||||
h2 {
|
||||
color: #e2e8f0;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,586 @@
|
||||
<template>
|
||||
<div v-can="permissionCodes.customer.read" class="customer-detail">
|
||||
<customer-profile-card
|
||||
class="customer"
|
||||
:customer="customer"
|
||||
/>
|
||||
<t-cell-group v-if="customer" class="info" theme="card">
|
||||
<t-cell title="出生日期">
|
||||
<span
|
||||
v-if="isLunarBirthdate"
|
||||
class="birthdate-trigger gray"
|
||||
type="button"
|
||||
@click.stop="toggleBirthdateText"
|
||||
>
|
||||
<span v-text="birthdateText" />
|
||||
</span>
|
||||
<span v-else v-text="birthdateText" />
|
||||
</t-cell>
|
||||
<t-cell title="年龄" :note="ageText" />
|
||||
<t-cell title="住址" :note="customer.address || '无'" />
|
||||
<t-cell title="病症" :note="customer.disease || '无'" />
|
||||
<t-cell title="备注" :note="customer.remark || '无'" />
|
||||
<t-cell title="录入用户" :note="creatorName" />
|
||||
<t-cell title="录入时间" :note="createdAtText" />
|
||||
</t-cell-group>
|
||||
<t-cell-group v-if="customer && canReadStat" theme="card">
|
||||
<t-cell title="客户数据图表" arrow @click="toCustomerDataCharts">
|
||||
<template #leftIcon>
|
||||
<t-icon name="chart" size="1.5rem" />
|
||||
</template>
|
||||
</t-cell>
|
||||
</t-cell-group>
|
||||
<t-cell-group theme="card">
|
||||
<div class="conditioning-part bg-white">
|
||||
<span class="gray">调理部位</span>
|
||||
<body-area-selector v-model="selectedAreas" />
|
||||
</div>
|
||||
</t-cell-group>
|
||||
<t-cell-group v-if="inviter" :title="`由 ${inviter.name} 邀请`" class="invite-list" theme="card">
|
||||
<customer-profile-card :customer="inviter" @click="toCustomerDetail(inviter)"/>
|
||||
</t-cell-group>
|
||||
<div class="invite-list">
|
||||
<div class="header">
|
||||
<div class="title">邀请客户</div>
|
||||
<div v-can="permissionCodes.customer.update" class="actions">
|
||||
<t-button
|
||||
theme="primary"
|
||||
variant="outline"
|
||||
size="extra-small"
|
||||
content="选择添加"
|
||||
@click="selectorVisible = true"
|
||||
/>
|
||||
<t-button
|
||||
theme="primary"
|
||||
size="extra-small"
|
||||
content="扫码添加"
|
||||
@click="openScanPopup"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<t-cell-group theme="card">
|
||||
<t-swipe-cell
|
||||
class="item"
|
||||
v-for="(item, index) in invitees"
|
||||
:key="item.id || item.code || index"
|
||||
>
|
||||
<t-cell
|
||||
class="cell"
|
||||
:title="item.name || item.code || '-'"
|
||||
arrow
|
||||
hover
|
||||
@click="toCustomerDetail(item)"
|
||||
>
|
||||
<template #description>
|
||||
<telephone-link :value="item.telephone" placeholder="未填写电话" />
|
||||
</template>
|
||||
<template #leftIcon>
|
||||
<t-image
|
||||
v-if="getCustomerPhotoURL(item)"
|
||||
class="avatar"
|
||||
:src="getCustomerPhotoURL(item)"
|
||||
:alt="item.name"
|
||||
fit="cover"
|
||||
/>
|
||||
<div v-else class="avatar fallback" v-text="(item.name || '客').slice(0, 1)" />
|
||||
</template>
|
||||
</t-cell>
|
||||
<template #right>
|
||||
<div v-can="permissionCodes.customer.update" class="remove bg-red white" @click="removeInvitee(item)">删除</div>
|
||||
</template>
|
||||
</t-swipe-cell>
|
||||
<div v-if="!invitees.length" class="empty">暂无邀请客户</div>
|
||||
</t-cell-group>
|
||||
</div>
|
||||
<div class="bottom-actions">
|
||||
<t-button
|
||||
v-can="permissionCodes.customer.delete"
|
||||
class="delete bg-white"
|
||||
theme="danger"
|
||||
variant="outline"
|
||||
:loading="isRemoving"
|
||||
@click="confirmRemoveCustomer"
|
||||
>
|
||||
删除客户
|
||||
</t-button>
|
||||
<t-button v-can="permissionCodes.customer.update" theme="primary" class="edit" @click="toEdit">编辑客户</t-button>
|
||||
</div>
|
||||
<customer-selector
|
||||
v-model="selectedInviteeId"
|
||||
v-model:visible="selectorVisible"
|
||||
@change="handleInviteeSelected"
|
||||
/>
|
||||
<t-popup v-model="scanPopupVisible" placement="bottom">
|
||||
<div class="scan-popup bg-white">
|
||||
<div class="header">
|
||||
<t-button
|
||||
variant="text"
|
||||
size="small"
|
||||
@click="closeScanPopup"
|
||||
content="取消"
|
||||
/>
|
||||
<h3>扫码添加邀请客户</h3>
|
||||
<t-button
|
||||
theme="primary"
|
||||
size="small"
|
||||
content="确认"
|
||||
@click="confirmScannedInvitee"
|
||||
/>
|
||||
</div>
|
||||
<code-scanner
|
||||
v-if="scanPopupVisible"
|
||||
:visible="scanPopupVisible"
|
||||
:status-text="scanStatus"
|
||||
active-text="正在识别客户编码,请将二维码移入取景框"
|
||||
@decode="handleDecodedText"
|
||||
@status-change="handleScanStatusChange"
|
||||
/>
|
||||
<div v-if="pendingInvitee" class="confirm">
|
||||
<customer-profile-card :customer="pendingInvitee" />
|
||||
</div>
|
||||
</div>
|
||||
</t-popup>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { DialogPlugin, Toast } from "tdesign-mobile-vue";
|
||||
import { Time, useUserStore } from "timi-web";
|
||||
import CustomerAPI from "@/api/CustomerAPI";
|
||||
import BodyAreaSelector from "@/components/BodyAreaSelector.vue";
|
||||
import CodeScanner from "@/components/CodeScanner.vue";
|
||||
import CustomerProfileCard from "@/components/CustomerProfileCard.vue";
|
||||
import CustomerSelector from "@/components/CustomerSelector.vue";
|
||||
import { useSettingStore } from "@/store/settingStore";
|
||||
import type { BodyAreaValue, Customer } from "@/types/Customer";
|
||||
import { getCustomerPhotoURL, normalizeBodyAreaValue } from "@/utils/customer";
|
||||
import { permissionCodes } from "@/utils/permission";
|
||||
import { LunarCalendar } from "@/utils/LunarCalendar";
|
||||
|
||||
defineOptions({
|
||||
name: "CustomerDetail"
|
||||
});
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const settingStore = useSettingStore();
|
||||
const userStore = useUserStore();
|
||||
|
||||
const customer = ref<Customer>();
|
||||
const invitees = ref<Customer[]>([]);
|
||||
const selectedInviteeId = ref("");
|
||||
const selectorVisible = ref(false);
|
||||
const scanPopupVisible = ref(false);
|
||||
const pendingInvitee = ref<Customer>();
|
||||
const scanStatus = ref("点击扫码开始识别。");
|
||||
const isRemoving = ref(false);
|
||||
const showSolarBirthdate = ref(false);
|
||||
let latestDecodedText = "";
|
||||
|
||||
const inviter = computed(() => customer.value?.introducerCustomer);
|
||||
const canReadStat = computed(() => userStore.hasPermissions(permissionCodes.stat.read));
|
||||
const creatorName = computed(() => {
|
||||
const creatorUser = customer.value?.creatorUser;
|
||||
return creatorUser?.nick || creatorUser?.name || customer.value?.creatorUserId || "无";
|
||||
});
|
||||
const createdAtText = computed(() => customer.value?.createdAt ? Time.toPassedDateTime(customer.value.createdAt) : "无");
|
||||
const isLunarBirthdate = computed(() => customer.value?.birthdateCalendar === "LUNAR" && !!customer.value?.birthdate);
|
||||
const solarBirthdateText = computed(() => formatSolarBirthdate(customer.value));
|
||||
const birthdateText = computed(() => showSolarBirthdate.value && isLunarBirthdate.value ? solarBirthdateText.value : formatBirthdate(customer.value));
|
||||
const ageText = computed(() => customer.value?.age === undefined || customer.value?.age === null ? "无" : `${customer.value.age} 岁`);
|
||||
const selectedAreas = computed<BodyAreaValue>({
|
||||
get() {
|
||||
return normalizeBodyAreaValue(customer.value?.conditioningPart);
|
||||
},
|
||||
set() {
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
async function loadCustomer(): Promise<void> {
|
||||
const id = String(route.params.id || "");
|
||||
if (!id) {
|
||||
customer.value = undefined;
|
||||
invitees.value = [];
|
||||
showSolarBirthdate.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
showSolarBirthdate.value = false;
|
||||
customer.value = await CustomerAPI.detail(id);
|
||||
await loadInvitees();
|
||||
} catch (err) {
|
||||
customer.value = undefined;
|
||||
invitees.value = [];
|
||||
console.warn("客户详情加载失败", err);
|
||||
Toast.warning("客户详情加载失败");
|
||||
}
|
||||
}
|
||||
|
||||
async function loadInvitees(): Promise<void> {
|
||||
const customerId = customer.value?.id;
|
||||
if (!customerId) {
|
||||
invitees.value = [];
|
||||
return;
|
||||
}
|
||||
|
||||
invitees.value = await CustomerAPI.listInvited(customerId);
|
||||
}
|
||||
|
||||
async function removeInvitee(item: Customer): Promise<void> {
|
||||
if (!item.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
await CustomerAPI.deleteInvite(item.id);
|
||||
await loadCustomer();
|
||||
Toast.success("已删除邀请关系");
|
||||
}
|
||||
|
||||
async function confirmRemoveCustomer(): Promise<void> {
|
||||
const currentCustomer = customer.value;
|
||||
if (!currentCustomer?.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
const customerId = currentCustomer.id;
|
||||
const customerName = currentCustomer.name || currentCustomer.code || "未命名客户";
|
||||
const dialog = DialogPlugin.confirm({
|
||||
title: "删除客户",
|
||||
content: `确定删除客户 ${customerName} 吗?删除后无法恢复。`,
|
||||
cancelBtn: "取消",
|
||||
confirmBtn: "删除",
|
||||
onCancel() {
|
||||
dialog.destroy();
|
||||
},
|
||||
async onConfirm() {
|
||||
dialog.destroy();
|
||||
await removeCustomer(customerId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function removeCustomer(id: string): Promise<void> {
|
||||
isRemoving.value = true;
|
||||
try {
|
||||
await CustomerAPI.remove(id);
|
||||
Toast.success("客户已删除");
|
||||
await router.replace("/management/customers");
|
||||
} finally {
|
||||
isRemoving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function toEdit(): Promise<void> {
|
||||
if (!customer.value?.id) {
|
||||
return;
|
||||
}
|
||||
await router.push(`/management/customers/${customer.value.id}/edit`);
|
||||
}
|
||||
|
||||
function formatBirthdate(value?: Customer): string {
|
||||
if (!value?.birthdate) {
|
||||
return "无";
|
||||
}
|
||||
if (value.birthdateCalendar !== "LUNAR") {
|
||||
return `公历 ${value.birthdate}`;
|
||||
}
|
||||
const lunarDate = LunarCalendar.solarToLunar(value.birthdate);
|
||||
return lunarDate ? LunarCalendar.formatLunarDate(lunarDate) : "农历日期";
|
||||
}
|
||||
|
||||
function formatSolarBirthdate(value?: Customer): string {
|
||||
return value?.birthdate ? `公历 ${value.birthdate}` : "无";
|
||||
}
|
||||
|
||||
function toggleBirthdateText(): void {
|
||||
if (isLunarBirthdate.value) {
|
||||
showSolarBirthdate.value = !showSolarBirthdate.value;
|
||||
}
|
||||
}
|
||||
|
||||
function isCustomerAncestor(invitee: Customer): boolean {
|
||||
if (!customer.value?.id) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let current = customer.value.introducerCustomer;
|
||||
while (current?.id) {
|
||||
if (current.id === invitee.id) {
|
||||
return true;
|
||||
}
|
||||
current = current.introducerCustomer;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
async function addInvitee(invitee: Customer): Promise<boolean> {
|
||||
if (!customer.value?.id || !invitee.id) {
|
||||
return false;
|
||||
}
|
||||
if (invitee.id === customer.value.id) {
|
||||
Toast.warning("不能把客户本人添加为邀请客户");
|
||||
return false;
|
||||
}
|
||||
if (isCustomerAncestor(invitee)) {
|
||||
Toast.warning("不能把上级邀请人添加为邀请客户");
|
||||
return false;
|
||||
}
|
||||
|
||||
await CustomerAPI.saveInvite(customer.value.id, invitee.id);
|
||||
await loadCustomer();
|
||||
Toast.success(`已添加 ${invitee.name || invitee.code}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
async function handleInviteeSelected(id: string): Promise<void> {
|
||||
selectedInviteeId.value = id;
|
||||
const invitee = await CustomerAPI.detail(id);
|
||||
await addInvitee(invitee);
|
||||
}
|
||||
|
||||
function openScanPopup(): void {
|
||||
pendingInvitee.value = undefined;
|
||||
latestDecodedText = "";
|
||||
scanStatus.value = "正在启动扫码预览";
|
||||
scanPopupVisible.value = true;
|
||||
}
|
||||
|
||||
function closeScanPopup(): void {
|
||||
scanPopupVisible.value = false;
|
||||
}
|
||||
|
||||
async function handleDecodedText(rawText: string): Promise<void> {
|
||||
const normalizedText = rawText.trim();
|
||||
if (!normalizedText || normalizedText === latestDecodedText) {
|
||||
return;
|
||||
}
|
||||
|
||||
latestDecodedText = normalizedText;
|
||||
await settingStore.playScanSuccessSound();
|
||||
const matched = await loadCustomerByCode(normalizedText);
|
||||
if (!matched) {
|
||||
scanStatus.value = `已识别 ${normalizedText},未匹配客户档案。`;
|
||||
return;
|
||||
}
|
||||
|
||||
pendingInvitee.value = matched;
|
||||
scanStatus.value = `已识别 ${matched.code},请确认是否添加 ${matched.name}。`;
|
||||
}
|
||||
|
||||
function handleScanStatusChange(value: string): void {
|
||||
scanStatus.value = value;
|
||||
}
|
||||
|
||||
async function confirmScannedInvitee(): Promise<void> {
|
||||
if (!pendingInvitee.value) {
|
||||
Toast.warning("请先扫码识别客户");
|
||||
return;
|
||||
}
|
||||
|
||||
if (await addInvitee(pendingInvitee.value)) {
|
||||
closeScanPopup();
|
||||
}
|
||||
}
|
||||
|
||||
async function loadCustomerByCode(code: string): Promise<Customer | undefined> {
|
||||
return await CustomerAPI.findByCode(code) || undefined;
|
||||
}
|
||||
|
||||
async function toCustomerDetail(target: Customer): Promise<void> {
|
||||
if (!target.id) {
|
||||
return;
|
||||
}
|
||||
await router.push(`/management/customers/${target.id}`);
|
||||
}
|
||||
|
||||
async function toCustomerDataCharts(): Promise<void> {
|
||||
if (!customer.value?.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
await router.push(`/management/customers/${customer.value.id}/data-charts`);
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadCustomer();
|
||||
});
|
||||
|
||||
watch(() => route.params.id, async () => {
|
||||
await loadCustomer();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.customer-detail {
|
||||
gap: 1rem;
|
||||
padding: calc(var(--app-nav-height) + 1rem) 0 4rem 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.customer {
|
||||
margin: 0 1rem;
|
||||
}
|
||||
|
||||
.info {
|
||||
|
||||
:deep(.t-cell__title-text) {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
:deep(.t-cell__note) {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
white-space: normal;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
}
|
||||
|
||||
.conditioning-part {
|
||||
gap: .5rem;
|
||||
padding: 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.invite-list {
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
padding: 0 var(--td-cell-group-title-padding-left, 16px);
|
||||
font-size: var(--td-cell-group-title-font-size, 14px);
|
||||
text-align: left;
|
||||
background: var(--td-cell-group-title-bg-color, var(--td-bg-color-secondarycontainer, var(--td-gray-color-1, #f3f3f3)));
|
||||
align-items: center;
|
||||
line-height: var(--td-cell-group-title-line-height, 45px);
|
||||
justify-content: space-between;
|
||||
|
||||
.title {
|
||||
color: var(--td-cell-group-title-color, var(--td-text-color-placeholder, var(--td-font-gray-3, rgba(0, 0, 0, .4))));
|
||||
}
|
||||
|
||||
.actions {
|
||||
gap: .875rem;
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
|
||||
.item {
|
||||
|
||||
&:last-child {
|
||||
|
||||
:deep(.t-cell:after) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.cell {
|
||||
padding: 6px 10px 6px 6px;
|
||||
|
||||
.avatar {
|
||||
width: 3.25rem;
|
||||
height: 3.25rem;
|
||||
border-radius: .875rem;
|
||||
|
||||
&.fallback {
|
||||
color: #2563eb;
|
||||
display: flex;
|
||||
font-size: 1.125rem;
|
||||
background: #dbeafe;
|
||||
align-items: center;
|
||||
font-weight: 600;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.remove {
|
||||
width: fit-content;
|
||||
padding: 0 1rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.empty {
|
||||
color: #52607a;
|
||||
padding: 1rem;
|
||||
text-align: center;
|
||||
background: #f7faff;
|
||||
border-radius: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.bottom-actions {
|
||||
gap: .75rem;
|
||||
left: 1rem;
|
||||
right: 1rem;
|
||||
display: flex;
|
||||
bottom: calc(.75rem + env(safe-area-inset-bottom, 0));
|
||||
position: fixed;
|
||||
|
||||
.delete {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.edit {
|
||||
flex: 2;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.birthdate-trigger {
|
||||
text-align: right;
|
||||
text-decoration: underline;
|
||||
text-underline-offset: .2rem;
|
||||
}
|
||||
|
||||
.scan-popup {
|
||||
gap: 1rem;
|
||||
padding: 1rem 1rem calc(1rem + env(safe-area-inset-bottom, 0)) 1rem;
|
||||
display: flex;
|
||||
overflow: auto;
|
||||
max-height: 86vh;
|
||||
border-radius: 1.25rem 1.25rem 0 0;
|
||||
flex-direction: column;
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
:global(html.theme-dark) {
|
||||
.customer-detail {
|
||||
|
||||
.profile-main p,
|
||||
.label,
|
||||
.meta,
|
||||
.sex-line,
|
||||
.empty {
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.scan-popup {
|
||||
color: #e2e8f0;
|
||||
background: rgba(15, 23, 42, .96);
|
||||
}
|
||||
|
||||
.avatar {
|
||||
|
||||
&.fallback {
|
||||
color: #93c5fd;
|
||||
background: rgba(30, 64, 175, .32);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,520 @@
|
||||
<template>
|
||||
<div v-can="savePermissionCode" class="customer-edit">
|
||||
<t-form class="form" label-align="left" label-width="6rem">
|
||||
<t-form-item label="客户编码">
|
||||
<t-input
|
||||
v-model.trim="formData.code"
|
||||
placeholder="客户编码"
|
||||
borderless
|
||||
>
|
||||
<template #suffix-icon>
|
||||
<t-button
|
||||
theme="primary"
|
||||
size="extra-small"
|
||||
@click="toggleScanner('code')"
|
||||
>
|
||||
<span v-text="scannerVisible && scannerTarget === 'code' ? '关闭扫码' : '扫码'" />
|
||||
</t-button>
|
||||
</template>
|
||||
</t-input>
|
||||
</t-form-item>
|
||||
<t-form-item v-if="isGlobalManager" label="门店" required-mark arrow @click="openStorePicker">
|
||||
<div class="picker-text" v-text="selectedStoreName" />
|
||||
</t-form-item>
|
||||
<div v-if="scannerVisible && scannerTarget === 'code'" class="code-scanner-wrapper">
|
||||
<code-scanner
|
||||
visible
|
||||
:status-text="scanStatus"
|
||||
active-text="正在识别客户编码。"
|
||||
@decode="handleDecodedText"
|
||||
@status-change="handleScanStatusChange"
|
||||
/>
|
||||
</div>
|
||||
<t-form-item label="姓名" required-mark>
|
||||
<t-input
|
||||
v-model.trim="formData.name"
|
||||
placeholder="姓名"
|
||||
borderless
|
||||
/>
|
||||
</t-form-item>
|
||||
<t-form-item label="出生日期">
|
||||
<birthdate-picker
|
||||
v-model="formData.birthdate"
|
||||
v-model:calendar="formData.birthdateCalendar"
|
||||
/>
|
||||
</t-form-item>
|
||||
<t-form-item label="年龄">
|
||||
<t-input
|
||||
v-model.trim="formData.age"
|
||||
type="number"
|
||||
:readonly="!!formData.birthdate"
|
||||
:placeholder="formData.birthdate ? '根据出生日期自动计算' : '年龄'"
|
||||
borderless
|
||||
/>
|
||||
</t-form-item>
|
||||
<t-form-item label="照片">
|
||||
<photo-field
|
||||
v-model="photoURL"
|
||||
alt-text="客户照片"
|
||||
:loading="isPhotoUploading"
|
||||
:preview="false"
|
||||
@add="openPhotoPicker"
|
||||
@clear="photoTempFileId = ''"
|
||||
/>
|
||||
</t-form-item>
|
||||
<t-form-item label="性别">
|
||||
<t-radio-group class="sex" v-model="formData.gender" variant="default-filled">
|
||||
<t-radio :block="false" borderless value="MALE">男</t-radio>
|
||||
<t-radio :block="false" borderless value="FEMALE">女</t-radio>
|
||||
<t-radio :block="false" borderless value="">未知</t-radio>
|
||||
</t-radio-group>
|
||||
</t-form-item>
|
||||
<t-form-item label="手机号">
|
||||
<t-input v-model.trim="formData.telephone" type="tel" placeholder="手机号" borderless />
|
||||
</t-form-item>
|
||||
<t-form-item label="介绍人">
|
||||
<div class="referrer">
|
||||
<t-input
|
||||
:value="formData.introducerCustomer?.name"
|
||||
readonly
|
||||
placeholder="选择或扫码"
|
||||
borderless
|
||||
/>
|
||||
<div class="actions">
|
||||
<t-button
|
||||
theme="primary"
|
||||
variant="outline"
|
||||
size="extra-small"
|
||||
@click="selectorVisible = true"
|
||||
>选择</t-button>
|
||||
<t-button theme="primary" size="extra-small" @click="toggleScanner('introducer')">
|
||||
<span v-text="scannerVisible && scannerTarget === 'introducer' ? '关闭扫码' : '扫码'" />
|
||||
</t-button>
|
||||
</div>
|
||||
</div>
|
||||
</t-form-item>
|
||||
<div v-if="scannerVisible && scannerTarget === 'introducer'" class="code-scanner-wrapper">
|
||||
<code-scanner
|
||||
visible
|
||||
:status-text="scanStatus"
|
||||
active-text="正在识别介绍人编码"
|
||||
@decode="handleDecodedText"
|
||||
@status-change="handleScanStatusChange"
|
||||
/>
|
||||
</div>
|
||||
<t-form-item label="住址">
|
||||
<t-textarea
|
||||
class="textarea"
|
||||
v-model.trim="formData.address"
|
||||
:autosize="{ minRows: 3, maxRows: 5 }"
|
||||
placeholder="住址"
|
||||
/>
|
||||
</t-form-item>
|
||||
<t-form-item label="病症">
|
||||
<t-textarea
|
||||
class="textarea"
|
||||
v-model.trim="formData.disease"
|
||||
:autosize="{ minRows: 3, maxRows: 5 }"
|
||||
placeholder="病症"
|
||||
/>
|
||||
</t-form-item>
|
||||
<div class="conditioning-part">
|
||||
<span class="gray">调理部位</span>
|
||||
<body-area-selector v-model="selectedAreas" />
|
||||
</div>
|
||||
<t-form-item label="备注">
|
||||
<t-textarea
|
||||
class="textarea"
|
||||
v-model.trim="formData.remark"
|
||||
:autosize="{ minRows: 3, maxRows: 5 }"
|
||||
placeholder="客户备注"
|
||||
/>
|
||||
</t-form-item>
|
||||
</t-form>
|
||||
<input
|
||||
ref="photoInputRef"
|
||||
class="file-input"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
@change="handlePhotoSelected"
|
||||
>
|
||||
<t-button
|
||||
theme="primary"
|
||||
class="save"
|
||||
:disabled="!canSubmit"
|
||||
:loading="isSubmitting"
|
||||
@click="doSubmit"
|
||||
>
|
||||
<template #icon>
|
||||
<t-icon name="check" />
|
||||
</template>
|
||||
保存
|
||||
</t-button>
|
||||
<customer-selector
|
||||
v-model="introducerCustomerId"
|
||||
v-model:visible="selectorVisible"
|
||||
@change="handleIntroducerChange"
|
||||
/>
|
||||
</div>
|
||||
<t-popup v-model="storePickerVisible" placement="bottom">
|
||||
<t-picker
|
||||
v-model="storePickerDraft"
|
||||
:columns="storePickerColumns"
|
||||
title="选择门店"
|
||||
@cancel="storePickerVisible = false"
|
||||
@confirm="confirmStorePicker"
|
||||
/>
|
||||
</t-popup>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { AttachmentAPI, useUserStore } from "timi-web";
|
||||
import { Toast } from "tdesign-mobile-vue";
|
||||
import CustomerAPI from "@/api/CustomerAPI";
|
||||
import BodyAreaSelector from "@/components/BodyAreaSelector.vue";
|
||||
import CodeScanner from "@/components/CodeScanner.vue";
|
||||
import CustomerSelector from "@/components/CustomerSelector.vue";
|
||||
import PhotoField from "@/components/PhotoField.vue";
|
||||
import { useCurrentStoreStore } from "@/store/currentStoreStore";
|
||||
import { useSettingStore } from "@/store/settingStore";
|
||||
import type { BodyAreaValue, Customer } from "@/types/Customer";
|
||||
import { createBodyAreaValue, normalizeBodyAreaValue } from "@/utils/customer";
|
||||
import { gaoRoleCodes, permissionCodes } from "@/utils/permission";
|
||||
import { compressUploadImage } from "@/utils/imageCompression";
|
||||
|
||||
type PickerValue = string | number;
|
||||
|
||||
defineOptions({
|
||||
name: "CustomerEdit"
|
||||
});
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const userStore = useUserStore();
|
||||
const settingStore = useSettingStore();
|
||||
const currentStoreStore = useCurrentStoreStore();
|
||||
const isCreateMode = computed(() => route.name === "CustomerCreate");
|
||||
const savePermissionCode = computed(() => isCreateMode.value ? permissionCodes.customer.create : permissionCodes.customer.update);
|
||||
const isGlobalManager = computed(() => userStore.hasRoles(gaoRoleCodes.globalManager));
|
||||
|
||||
let latestDecodedText = "";
|
||||
|
||||
// ---------- 扫码 ----------
|
||||
|
||||
type ScanTarget = "code" | "introducer";
|
||||
const scannerVisible = ref(false);
|
||||
const scannerTarget = ref<ScanTarget>("code");
|
||||
const scanStatus = ref("点击扫码开始识别");
|
||||
const introducerCustomerId = ref("");
|
||||
|
||||
async function toggleScanner(target: ScanTarget): Promise<void> {
|
||||
if (scannerVisible.value && scannerTarget.value === target) {
|
||||
scannerVisible.value = false;
|
||||
return;
|
||||
}
|
||||
scannerTarget.value = target;
|
||||
scannerVisible.value = true;
|
||||
latestDecodedText = "";
|
||||
}
|
||||
|
||||
async function handleDecodedText(rawText: string): Promise<void> {
|
||||
const normalizedText = rawText.trim();
|
||||
if (!normalizedText || normalizedText === latestDecodedText) {
|
||||
return;
|
||||
}
|
||||
latestDecodedText = normalizedText;
|
||||
await settingStore.playScanSuccessSound();
|
||||
if (scannerTarget.value === "code") {
|
||||
// 选择客户扫码
|
||||
formData.code = normalizedText;
|
||||
scanStatus.value = `已识别客户编码 ${normalizedText}`;
|
||||
scannerVisible.value = false;
|
||||
return;
|
||||
} else {
|
||||
// 选择介绍人扫码
|
||||
await applyIntroducer(normalizedText);
|
||||
}
|
||||
}
|
||||
|
||||
function handleScanStatusChange(value: string): void {
|
||||
scanStatus.value = value;
|
||||
}
|
||||
|
||||
// ---------- 介绍人 ----------
|
||||
|
||||
const selectorVisible = ref(false);
|
||||
|
||||
async function applyIntroducer(code: string): Promise<void> {
|
||||
if (!code) {
|
||||
formData.introducerCustomerId = "";
|
||||
introducerCustomerId.value = "";
|
||||
scannerVisible.value = false;
|
||||
return;
|
||||
}
|
||||
const matched = await CustomerAPI.findByCode(code);
|
||||
if (!matched?.id) {
|
||||
formData.introducerCustomerId = "";
|
||||
formData.introducerCustomer = undefined;
|
||||
introducerCustomerId.value = "";
|
||||
scannerVisible.value = false;
|
||||
Toast.warning(`未匹配到 ${code} 编码介绍人,请选择或扫码已有客户`);
|
||||
return;
|
||||
}
|
||||
formData.introducerCustomerId = matched.id;
|
||||
formData.introducerCustomer = matched;
|
||||
introducerCustomerId.value = matched.id;
|
||||
scannerVisible.value = false;
|
||||
}
|
||||
|
||||
async function handleIntroducerChange(id: string): Promise<void> {
|
||||
const customer = id ? await CustomerAPI.findById(id) : undefined;
|
||||
introducerCustomerId.value = customer?.id || "";
|
||||
formData.introducerCustomerId = customer?.id || "";
|
||||
formData.introducerCustomer = customer;
|
||||
}
|
||||
|
||||
// ---------- 表单 ----------
|
||||
|
||||
const photoInputRef = ref<HTMLInputElement>();
|
||||
const photoTempFileId = ref("");
|
||||
const isPhotoUploading = ref(false);
|
||||
const photoURL = ref();
|
||||
const selectedAreas = ref<BodyAreaValue>(createBodyAreaValue());
|
||||
const formData = reactive<Customer>({
|
||||
});
|
||||
|
||||
function openPhotoPicker(): void {
|
||||
photoInputRef.value?.click();
|
||||
}
|
||||
|
||||
const canSubmit = computed(() => formData.name?.trim() && formData.storeId);
|
||||
|
||||
// ---------- 门店选择 ----------
|
||||
|
||||
const storePickerVisible = ref(false);
|
||||
const storePickerDraft = ref<PickerValue[]>([]);
|
||||
const storePickerColumns = computed(() => [
|
||||
currentStoreStore.storeList.map(item => ({
|
||||
label: item.name || item.code || "未命名门店",
|
||||
value: item.id || ""
|
||||
})).filter(item => item.value)
|
||||
]);
|
||||
const selectedStoreName = computed(() => {
|
||||
const store = currentStoreStore.storeList.find(item => item.id === formData.storeId);
|
||||
return store?.name || store?.code || "请选择门店";
|
||||
});
|
||||
|
||||
function openStorePicker(): void {
|
||||
if (!currentStoreStore.storeList.length) {
|
||||
Toast.warning("暂无可选门店");
|
||||
return;
|
||||
}
|
||||
|
||||
storePickerDraft.value = [formData.storeId || ""];
|
||||
storePickerVisible.value = true;
|
||||
}
|
||||
|
||||
function confirmStorePicker(): void {
|
||||
const storeId = String(storePickerDraft.value[0] || "");
|
||||
if (!storeId) {
|
||||
return;
|
||||
}
|
||||
|
||||
formData.storeId = storeId;
|
||||
currentStoreStore.selectStore(storeId);
|
||||
storePickerVisible.value = false;
|
||||
}
|
||||
|
||||
async function prepareStoreScope(): Promise<void> {
|
||||
if (isGlobalManager.value) {
|
||||
await currentStoreStore.loadStoreList();
|
||||
const queryStoreId = String(route.query.storeId || "");
|
||||
formData.storeId = currentStoreStore.storeList.some(item => item.id === queryStoreId)
|
||||
? queryStoreId
|
||||
: currentStoreStore.currentStoreId;
|
||||
return;
|
||||
}
|
||||
|
||||
const store = await currentStoreStore.loadLoginStore();
|
||||
formData.storeId = store?.id || "";
|
||||
}
|
||||
|
||||
async function loadDetail() {
|
||||
await prepareStoreScope();
|
||||
const id = String(route.params.id || "");
|
||||
if (!id) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const customer = await CustomerAPI.detail(id);
|
||||
Object.assign(formData, customer);
|
||||
formData.storeId = customer.storeId || formData.storeId;
|
||||
selectedAreas.value = normalizeBodyAreaValue(customer.conditioningPart);
|
||||
introducerCustomerId.value = customer.introducerCustomerId || customer.introducerCustomer?.id || "";
|
||||
} catch (err) {
|
||||
console.warn("客户详情加载失败", err);
|
||||
Toast.error("客户详情加载失败");
|
||||
}
|
||||
}
|
||||
onMounted(loadDetail);
|
||||
watch(() => route.fullPath, loadDetail);
|
||||
|
||||
// ---------- 年龄 ----------
|
||||
|
||||
watch(() => formData.birthdate, (value) => {
|
||||
if (value) {
|
||||
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value);
|
||||
if (match) {
|
||||
const birthYear = Number(match[1]);
|
||||
const birthMonth = Number(match[2]);
|
||||
const birthDay = Number(match[3]);
|
||||
const today = new Date();
|
||||
let age = today.getFullYear() - birthYear;
|
||||
if (today.getMonth() + 1 < birthMonth || (today.getMonth() + 1 === birthMonth && today.getDate() < birthDay)) {
|
||||
age--;
|
||||
}
|
||||
formData.age = Math.max(age, 0);
|
||||
} else {
|
||||
formData.age = undefined;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// ---------- 照片 ----------
|
||||
|
||||
async function handlePhotoSelected(event: Event): Promise<void> {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
isPhotoUploading.value = true;
|
||||
try {
|
||||
const compressedFile = await compressUploadImage(file);
|
||||
const [tempFile] = await AttachmentAPI.upload(compressedFile);
|
||||
photoTempFileId.value = tempFile.id;
|
||||
photoURL.value = AttachmentAPI.getTempReadURL(tempFile.id);
|
||||
} finally {
|
||||
isPhotoUploading.value = false;
|
||||
input.value = "";
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 保存 ----------
|
||||
|
||||
const isSubmitting = ref(false);
|
||||
|
||||
async function doSubmit(): Promise<void> {
|
||||
if (!formData.storeId) {
|
||||
Toast.warning("请选择门店");
|
||||
return;
|
||||
}
|
||||
|
||||
isSubmitting.value = true;
|
||||
try {
|
||||
const payload: Customer = { ...formData };
|
||||
payload.conditioningPart = selectedAreas.value;
|
||||
payload.introducerCustomerId = introducerCustomerId.value || "";
|
||||
payload.attachmentList = photoTempFileId.value ? [{
|
||||
attachType: "PHOTO",
|
||||
tempFileId: photoTempFileId.value
|
||||
}] : undefined;
|
||||
delete payload.introducerCustomer;
|
||||
|
||||
if (isCreateMode.value) {
|
||||
await CustomerAPI.create(payload);
|
||||
Toast.success("创建成功");
|
||||
const redirect = String(route.query.redirect || "");
|
||||
await router.replace(redirect || "/management/customers");
|
||||
return;
|
||||
} else {
|
||||
await CustomerAPI.update(payload);
|
||||
Toast.success("保存成功");
|
||||
const redirect = String(route.query.redirect || "");
|
||||
if (redirect) {
|
||||
await router.replace(redirect);
|
||||
return;
|
||||
}
|
||||
}
|
||||
await router.replace("/management/customers");
|
||||
} finally {
|
||||
isSubmitting.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.customer-edit {
|
||||
padding: calc(var(--app-nav-height) + 1rem) 0 4rem 0;
|
||||
|
||||
.form {
|
||||
padding: .5rem 0;
|
||||
background: rgba(255, 255, 255, .92);
|
||||
box-shadow: var(--td-shadow-1);
|
||||
border-radius: 1.4rem;
|
||||
|
||||
.code-scanner-wrapper {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.textarea {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.referrer {
|
||||
gap: .75rem;
|
||||
display: flex;
|
||||
|
||||
.actions {
|
||||
gap: .5rem;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
|
||||
.sex {
|
||||
gap: 1rem;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.conditioning-part {
|
||||
gap: .5rem;
|
||||
padding: 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
.file-input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.save {
|
||||
left: 1rem;
|
||||
right: 1rem;
|
||||
bottom: calc(.75rem + env(safe-area-inset-bottom, 0));
|
||||
position: fixed;
|
||||
}
|
||||
}
|
||||
|
||||
:global(html.theme-dark) {
|
||||
.customer-edit {
|
||||
|
||||
:deep(.form-item .t-form__label) {
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.form {
|
||||
background: rgba(15, 23, 42, .88);
|
||||
box-shadow: 0 .75rem 2rem rgba(2, 6, 23, .32);
|
||||
}
|
||||
|
||||
:deep(.textarea .t-textarea__inner) {
|
||||
color: #e2e8f0;
|
||||
background: rgba(15, 23, 42, .72);
|
||||
border-color: rgba(148, 163, 184, .16);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,341 @@
|
||||
<template>
|
||||
<div v-can="permissionCodes.customer.read" class="customer-list">
|
||||
<div class="filter-bar">
|
||||
<t-dropdown-menu v-if="isGlobalManager">
|
||||
<t-dropdown-item
|
||||
v-model="selectedStoreId"
|
||||
:label="storeFilterLabel"
|
||||
:options="storeOptions"
|
||||
@change="selectStoreFilter"
|
||||
/>
|
||||
</t-dropdown-menu>
|
||||
<t-input
|
||||
v-model.trim="keyword"
|
||||
class="text-input"
|
||||
placeholder="搜索编码、姓名、电话"
|
||||
clearable
|
||||
borderless
|
||||
/>
|
||||
</div>
|
||||
<t-pull-down-refresh
|
||||
class="list-wrapper"
|
||||
v-model="isRefreshing"
|
||||
:refresh-timeout="30000"
|
||||
@refresh="refresh"
|
||||
@scrolltolower="fetch"
|
||||
>
|
||||
<t-cell-group class="items" theme="card">
|
||||
<t-cell
|
||||
class="item"
|
||||
v-for="(item, index) in pageResult.list"
|
||||
:key="item.id || item.code || index"
|
||||
arrow
|
||||
@click="router.push(`/management/customers/${item.id}`)"
|
||||
>
|
||||
<template #left-icon>
|
||||
<t-image
|
||||
v-if="getCustomerPhotoURL(item)"
|
||||
class="avatar"
|
||||
:src="getCustomerPhotoURL(item)"
|
||||
:alt="item.name"
|
||||
fit="cover"
|
||||
/>
|
||||
<div v-else class="avatar fallback" v-text="(item.name || item.code || '客').slice(0, 1)" />
|
||||
</template>
|
||||
<template #title>
|
||||
<div class="header">
|
||||
<div class="left">
|
||||
<div class="text-bold" v-text="item.name" />
|
||||
<gender-icon :value="item.gender" size="1.125rem" />
|
||||
</div>
|
||||
<span class="gray" v-text="item.code || ''" />
|
||||
</div>
|
||||
</template>
|
||||
<template #description>
|
||||
<telephone-link :value="item.telephone" />
|
||||
</template>
|
||||
</t-cell>
|
||||
</t-cell-group>
|
||||
<div v-if="!pageResult.list.length" class="load-state" v-text="isLoading ? '正在加载客户' : '暂无客户数据'" />
|
||||
<div v-if="pageResult.list.length" class="load-state" v-text="isLoading ? '正在加载更多' : isNoMore ? '已经到底了' : '上拉加载更多'" />
|
||||
</t-pull-down-refresh>
|
||||
<div class="bottom-actions">
|
||||
<t-button
|
||||
v-can="permissionCodes.customer.export"
|
||||
theme="primary"
|
||||
variant="outline"
|
||||
class="export bg-white"
|
||||
:loading="isExporting"
|
||||
:disabled="isExporting"
|
||||
@click="doExport"
|
||||
>
|
||||
<template #icon>
|
||||
<t-icon name="download" />
|
||||
</template>
|
||||
导出
|
||||
</t-button>
|
||||
<t-button
|
||||
v-can="permissionCodes.customer.create"
|
||||
theme="primary"
|
||||
class="create"
|
||||
@click="toCreate"
|
||||
>
|
||||
<template #icon>
|
||||
<t-icon name="add" />
|
||||
</template>
|
||||
添加
|
||||
</t-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Toast } from "tdesign-mobile-vue";
|
||||
import { type DropdownValue } from "tdesign-mobile-vue";
|
||||
import { type Page, type PageResult, Time, Toolkit, useUserStore } from "timi-web";
|
||||
import CustomerAPI from "@/api/CustomerAPI";
|
||||
import GenderIcon from "@/components/GenderIcon.vue";
|
||||
import { useCurrentStoreStore } from "@/store/currentStoreStore";
|
||||
import type { Customer } from "@/types/Customer";
|
||||
import { getCustomerPhotoURL } from "@/utils/customer";
|
||||
import { gaoRoleCodes, permissionCodes } from "@/utils/permission";
|
||||
|
||||
defineOptions({
|
||||
name: "CustomerList"
|
||||
});
|
||||
|
||||
const router = useRouter();
|
||||
const userStore = useUserStore();
|
||||
const currentStoreStore = useCurrentStoreStore();
|
||||
const isRefreshing = ref(false);
|
||||
const isExporting = ref(false);
|
||||
const isGlobalManager = computed(() => userStore.hasRoles(gaoRoleCodes.globalManager));
|
||||
|
||||
// ---------- 门店筛选 ----------
|
||||
|
||||
const selectedStoreId = ref(currentStoreStore.currentStoreId);
|
||||
const storeOptions = computed(() => currentStoreStore.storeList
|
||||
.map(item => ({
|
||||
label: item.name || item.code || "未命名门店",
|
||||
value: item.id || ""
|
||||
}))
|
||||
.filter(item => item.value));
|
||||
const storeFilterLabel = computed(() => storeOptions.value.find(item => item.value === selectedStoreId.value)?.label || "选择门店");
|
||||
|
||||
async function selectStoreFilter(value: DropdownValue): Promise<void> {
|
||||
if (Array.isArray(value)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const storeId = String(value);
|
||||
selectedStoreId.value = storeId;
|
||||
currentStoreStore.selectStore(storeId);
|
||||
await refresh();
|
||||
}
|
||||
|
||||
// ---------- 分页 ----------
|
||||
|
||||
const keyword = ref();
|
||||
const isLoading = ref(false);
|
||||
const isNoMore = ref(false);
|
||||
const pageResult = reactive<PageResult<Customer>>({
|
||||
total: 0,
|
||||
list: []
|
||||
});
|
||||
const page = reactive<Page<Customer>>({
|
||||
index: 0,
|
||||
size: 15
|
||||
});
|
||||
|
||||
const fetch = Toolkit.debounce(async () => {
|
||||
const result = await CustomerAPI.list({
|
||||
...page,
|
||||
likesExample: {
|
||||
code: keyword.value,
|
||||
name: keyword.value,
|
||||
telephone: keyword.value
|
||||
}
|
||||
});
|
||||
pageResult.total = result.total;
|
||||
pageResult.list = pageResult.list.concat(result.list);
|
||||
page.index += 1;
|
||||
isNoMore.value = (result.list || []).length < page.size || pageResult.total <= pageResult.list.length;
|
||||
});
|
||||
|
||||
async function refresh(): Promise<void> {
|
||||
isRefreshing.value = true;
|
||||
try {
|
||||
page.index = 0;
|
||||
page.size = 15;
|
||||
pageResult.total = 0;
|
||||
pageResult.list = [];
|
||||
fetch();
|
||||
} finally {
|
||||
isRefreshing.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadData(): Promise<void> {
|
||||
if (isGlobalManager.value) {
|
||||
await currentStoreStore.loadStoreList();
|
||||
selectedStoreId.value = currentStoreStore.currentStoreId;
|
||||
} else {
|
||||
await currentStoreStore.loadLoginStore();
|
||||
}
|
||||
await fetch();
|
||||
}
|
||||
|
||||
onMounted(loadData);
|
||||
|
||||
// ---------- 导航 ----------
|
||||
|
||||
async function toCreate(): Promise<void> {
|
||||
await router.push({
|
||||
path: "/management/customers/create",
|
||||
query: isGlobalManager.value && selectedStoreId.value ? { storeId: selectedStoreId.value } : undefined
|
||||
});
|
||||
}
|
||||
|
||||
// ---------- 导出 ----------
|
||||
|
||||
async function doExport(): Promise<void> {
|
||||
if (isExporting.value) {
|
||||
return;
|
||||
}
|
||||
isExporting.value = true;
|
||||
try {
|
||||
const blob = await CustomerAPI.exportXSSF(page);
|
||||
Toolkit.downloadBlob(blob, `客户列表-${Time.formatDate(new Date())}.xlsx`);
|
||||
Toast.success("导出已开始");
|
||||
} finally {
|
||||
isExporting.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.customer-list {
|
||||
padding: calc(var(--app-nav-height)) 0 5.5rem 0;
|
||||
|
||||
.filter-bar {
|
||||
top: var(--app-nav-height);
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 10;
|
||||
gap: .5rem;
|
||||
display: flex;
|
||||
padding: .75rem 1rem;
|
||||
position: sticky;
|
||||
flex-direction: column;
|
||||
background: #F5F5F8;
|
||||
box-shadow: var(--td-shadow-1);
|
||||
|
||||
.text-input {
|
||||
width: 100%;
|
||||
border: 1px solid rgba(148, 163, 184, .28);
|
||||
padding: .5rem 1rem;
|
||||
background: #f8fbff;
|
||||
border-radius: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.list-wrapper {
|
||||
padding: 1rem 0 4rem 0;
|
||||
|
||||
.items {
|
||||
|
||||
.item {
|
||||
padding: 10px;
|
||||
|
||||
.avatar {
|
||||
width: 3.5rem;
|
||||
height: 3.5rem;
|
||||
border-radius: .5rem;
|
||||
}
|
||||
|
||||
.header {
|
||||
gap: .5rem;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
|
||||
.left {
|
||||
gap: .5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.bottom-actions {
|
||||
gap: .75rem;
|
||||
left: 1rem;
|
||||
right: 1rem;
|
||||
display: flex;
|
||||
bottom: calc(.75rem + env(safe-area-inset-bottom, 0));
|
||||
position: fixed;
|
||||
|
||||
.export {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.create {
|
||||
flex: 2;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
:global(html.theme-dark) {
|
||||
.customer-list {
|
||||
|
||||
.filter-bar {
|
||||
background: #10161f;
|
||||
|
||||
.text-input {
|
||||
color: #e2e8f0;
|
||||
background: rgba(15, 23, 42, .72);
|
||||
border-color: rgba(148, 163, 184, .16);
|
||||
}
|
||||
}
|
||||
|
||||
.items {
|
||||
|
||||
.load-state,
|
||||
.item,
|
||||
.empty-block {
|
||||
color: #e2e8f0;
|
||||
background: rgba(30, 41, 59, .9);
|
||||
border-color: rgba(148, 163, 184, .16);
|
||||
}
|
||||
|
||||
.avatar {
|
||||
|
||||
&.fallback {
|
||||
color: #93c5fd;
|
||||
background: rgba(30, 64, 175, .32);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.stats {
|
||||
|
||||
.stat {
|
||||
background: rgba(30, 41, 59, .9);
|
||||
border-color: rgba(148, 163, 184, .16);
|
||||
|
||||
.label {
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.value {
|
||||
color: #e2e8f0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,361 @@
|
||||
<template>
|
||||
<div v-can="permissionCodes.qrCode.create" class="qr-page">
|
||||
<t-form class="form" label-align="left">
|
||||
<t-form-item label="编码前缀">
|
||||
<t-input v-model.trim="formModel.prefix" placeholder="如 GAO" borderless />
|
||||
</t-form-item>
|
||||
<t-form-item label="起始编号">
|
||||
<t-input
|
||||
:model-value="formModel.start"
|
||||
type="number"
|
||||
inputmode="numeric"
|
||||
borderless
|
||||
@change="value => handleNumberChange('start', value)"
|
||||
/>
|
||||
</t-form-item>
|
||||
<t-form-item label="编号位数" contentAlign="right">
|
||||
<t-stepper
|
||||
v-model="formModel.digits"
|
||||
:min="startMinDigits"
|
||||
:max="maxDigits"
|
||||
default-value="3"
|
||||
theme="outline"
|
||||
/>
|
||||
</t-form-item>
|
||||
<t-form-item label="生成数量">
|
||||
<t-input
|
||||
:model-value="formModel.quantity"
|
||||
type="number"
|
||||
inputmode="numeric"
|
||||
borderless
|
||||
@change="value => handleNumberChange('quantity', value)"
|
||||
/>
|
||||
</t-form-item>
|
||||
</t-form>
|
||||
<t-cell-group theme="card">
|
||||
<t-cell title="最终起始编码" :note="finalStartCode" />
|
||||
<t-cell title="最终结束编码" :note="finalEndCode" />
|
||||
</t-cell-group>
|
||||
|
||||
<section class="preview">
|
||||
<div class="header">
|
||||
<h2 class="title">预览</h2>
|
||||
<p class="tips" v-text="previewSummary" />
|
||||
</div>
|
||||
<div class="paper" :style="paperStyle">
|
||||
<div
|
||||
class="item"
|
||||
v-for="item in visiblePreviewPages[0].codes"
|
||||
:key="item"
|
||||
>
|
||||
<div class="qr-code" />
|
||||
<div class="text-mark bg-gray" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<div class="actions">
|
||||
<t-button class="bg-white" theme="primary" variant="outline" block :loading="printing" @click="printQrCode">打印</t-button>
|
||||
<t-button theme="primary" block :loading="downloading" @click="downloadImages">
|
||||
<span v-text="downloadText" />
|
||||
</t-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Toast } from "tdesign-mobile-vue";
|
||||
import {
|
||||
calculateQrCodeGrid,
|
||||
downloadQrCodeImages,
|
||||
printQrCodeImages,
|
||||
splitQrCodePages
|
||||
} from "@/utils/qrCode";
|
||||
import { permissionCodes } from "@/utils/permission";
|
||||
|
||||
defineOptions({
|
||||
name: "CustomerQrCode"
|
||||
});
|
||||
|
||||
interface QrCodeFormModel {
|
||||
prefix: string;
|
||||
start: string;
|
||||
digits: number;
|
||||
quantity: string;
|
||||
}
|
||||
|
||||
const minDigits = 1;
|
||||
const maxDigits = 16;
|
||||
const defaultDigits = 3;
|
||||
const storageKey = "management-qr-code-config";
|
||||
|
||||
const formModel = reactive<QrCodeFormModel>({
|
||||
prefix: "GAO",
|
||||
start: "1",
|
||||
digits: defaultDigits,
|
||||
quantity: "22"
|
||||
});
|
||||
const downloading = ref(false);
|
||||
const printing = ref(false);
|
||||
|
||||
const codeList = computed(() => {
|
||||
const start = Number(formModel.start || 0);
|
||||
const digits = effectiveDigits.value;
|
||||
const quantity = Number(formModel.quantity || 1);
|
||||
|
||||
return Array.from({ length: quantity }, (_, index) => {
|
||||
const codeNumber = String(start + index).padStart(digits, "0");
|
||||
return `${formModel.prefix}${codeNumber}`;
|
||||
});
|
||||
});
|
||||
const startMinDigits = computed(() => Math.max(minDigits, Math.min(maxDigits, normalizeNumber(formModel.start).length || minDigits)));
|
||||
const effectiveDigits = computed(() => Math.max(startMinDigits.value, Math.min(maxDigits, formModel.digits || defaultDigits)));
|
||||
const maxCodeLength = computed(() => formModel.prefix.length + effectiveDigits.value);
|
||||
const previewPages = computed(() => splitQrCodePages(codeList.value, maxCodeLength.value));
|
||||
const visiblePreviewPages = computed(() => previewPages.value.slice(0, 1));
|
||||
const paperGrid = computed(() => calculateQrCodeGrid(maxCodeLength.value));
|
||||
const paperStyle = computed(() => ({
|
||||
gridTemplateRows: `repeat(${paperGrid.value.rows}, minmax(0, 1fr))`,
|
||||
gridTemplateColumns: `repeat(${paperGrid.value.columns}, minmax(0, 1fr))`
|
||||
}));
|
||||
const paperCount = computed(() => previewPages.value.length);
|
||||
const finalStartCode = computed(() => codeList.value[0] || "");
|
||||
const finalEndCode = computed(() => codeList.value[codeList.value.length - 1] || "");
|
||||
const downloadText = computed(() => `下载图片(${paperCount.value} 张)`);
|
||||
const previewSummary = computed(() => `共 ${paperCount.value} 张 A4,每张最多 ${paperGrid.value.pageSize} 枚,${paperGrid.value.columns} × ${paperGrid.value.rows} 网格`);
|
||||
|
||||
function normalizeNumber(value: string | number | undefined): string {
|
||||
return String(value ?? "").replace(/\D/g, "");
|
||||
}
|
||||
|
||||
function normalizeDigits(value: string | number | undefined, minValue = minDigits): number {
|
||||
const nextValue = Number(normalizeNumber(value));
|
||||
const validValue = Number.isFinite(nextValue) && 0 < nextValue ? nextValue : defaultDigits;
|
||||
return Math.max(minValue, Math.min(maxDigits, validValue));
|
||||
}
|
||||
|
||||
function loadLocalConfig(): void {
|
||||
if (typeof window === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
const cache = window.localStorage.getItem(storageKey);
|
||||
if (!cache) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const config = JSON.parse(cache) as Partial<QrCodeFormModel>;
|
||||
const start = normalizeNumber(config.start);
|
||||
const startDigits = Math.max(minDigits, Math.min(maxDigits, start.length || minDigits));
|
||||
|
||||
formModel.prefix = String(config.prefix ?? formModel.prefix);
|
||||
formModel.start = start;
|
||||
formModel.digits = normalizeDigits(config.digits, startDigits);
|
||||
formModel.quantity = normalizeNumber(config.quantity) || formModel.quantity;
|
||||
} catch {
|
||||
window.localStorage.removeItem(storageKey);
|
||||
}
|
||||
}
|
||||
|
||||
function saveLocalConfig(): void {
|
||||
if (typeof window === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
window.localStorage.setItem(storageKey, JSON.stringify({
|
||||
prefix: formModel.prefix,
|
||||
start: formModel.start,
|
||||
digits: formModel.digits,
|
||||
quantity: formModel.quantity
|
||||
}));
|
||||
}
|
||||
|
||||
function handleNumberChange(key: "start" | "quantity", value: string | number | undefined): void {
|
||||
const nextValue = normalizeNumber(value);
|
||||
formModel[key] = nextValue;
|
||||
}
|
||||
|
||||
loadLocalConfig();
|
||||
|
||||
watch(startMinDigits, value => {
|
||||
if (formModel.digits < value) {
|
||||
formModel.digits = value;
|
||||
}
|
||||
});
|
||||
|
||||
watch(formModel, () => {
|
||||
saveLocalConfig();
|
||||
}, {
|
||||
deep: true
|
||||
});
|
||||
|
||||
async function downloadImages(): Promise<void> {
|
||||
if (downloading.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
downloading.value = true;
|
||||
|
||||
try {
|
||||
await downloadQrCodeImages({
|
||||
codes: codeList.value,
|
||||
filePrefix: `${finalStartCode.value}-${finalEndCode.value}`,
|
||||
maxCodeLength: maxCodeLength.value
|
||||
});
|
||||
} finally {
|
||||
downloading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function printQrCode(): Promise<void> {
|
||||
if (printing.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
printing.value = true;
|
||||
try {
|
||||
await printQrCodeImages({
|
||||
codes: codeList.value,
|
||||
maxCodeLength: maxCodeLength.value,
|
||||
title: `${finalStartCode.value}-${finalEndCode.value}`
|
||||
});
|
||||
} catch (err) {
|
||||
Toast({
|
||||
theme: "warning",
|
||||
direction: "row",
|
||||
message: err instanceof Error ? err.message : "打印失败"
|
||||
});
|
||||
} finally {
|
||||
printing.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.qr-page {
|
||||
gap: 1rem;
|
||||
min-height: 100vh;
|
||||
padding: calc(var(--app-nav-height) + 1rem) 0 6rem 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.form {
|
||||
padding: .5rem 0;
|
||||
background: rgba(255, 255, 255, .92);
|
||||
box-shadow: var(--td-shadow-1);
|
||||
border-radius: 1.4rem;
|
||||
|
||||
:deep(.t-input__control) {
|
||||
text-align: right;
|
||||
}
|
||||
}
|
||||
|
||||
.preview {
|
||||
padding: 0 1rem;
|
||||
|
||||
.header {
|
||||
margin: 0 0 .75rem 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
|
||||
.title {
|
||||
color: #0f172a;
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.tips {
|
||||
color: #52607a;
|
||||
margin: 0;
|
||||
font-size: .8125rem;
|
||||
}
|
||||
}
|
||||
|
||||
.paper {
|
||||
gap: 0;
|
||||
width: min(100%, 28rem);
|
||||
padding: .75rem;
|
||||
display: grid;
|
||||
margin: 0 auto;
|
||||
aspect-ratio: 210 / 297;
|
||||
background: #fff;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 .25rem 1rem rgba(15, 23, 42, .2);
|
||||
|
||||
.item {
|
||||
gap: .1875rem;
|
||||
border: 1px dashed #cbd5e1;
|
||||
padding: .25rem;
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
|
||||
.qr-code {
|
||||
width: 58%;
|
||||
background: repeating-linear-gradient(45deg, #000 0 .125rem, #000 .125rem .25rem, #fff .25rem .375rem, #fff .375rem .5rem);
|
||||
aspect-ratio: 1;
|
||||
}
|
||||
|
||||
.text-mark {
|
||||
width: 50%;
|
||||
height: 3px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.actions {
|
||||
gap: .75rem;
|
||||
left: 1rem;
|
||||
right: 1rem;
|
||||
bottom: calc(.75rem + env(safe-area-inset-bottom, 0));
|
||||
display: grid;
|
||||
position: fixed;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
:global(html.theme-dark) {
|
||||
.qr-page {
|
||||
.form {
|
||||
background: rgba(15, 23, 42, .88);
|
||||
box-shadow: 0 .75rem 2rem rgba(2, 6, 23, .32);
|
||||
}
|
||||
|
||||
.code-range {
|
||||
div {
|
||||
background: rgba(15, 23, 42, .88);
|
||||
box-shadow: 0 .75rem 2rem rgba(2, 6, 23, .32);
|
||||
}
|
||||
|
||||
span {
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
strong {
|
||||
color: #e2e8f0;
|
||||
}
|
||||
}
|
||||
|
||||
.preview-head {
|
||||
h2 {
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
p {
|
||||
color: #94a3b8;
|
||||
}
|
||||
}
|
||||
|
||||
.paper {
|
||||
background: #f8fafc;
|
||||
box-shadow: 0 .75rem 2rem rgba(2, 6, 23, .32);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,629 +0,0 @@
|
||||
<template>
|
||||
<div v-can="permissionCodes.eventRecord.create" class="quick-entry-page">
|
||||
<t-form class="form" label-align="left" label-width="6rem">
|
||||
<t-form-item label="客户编码" required-mark>
|
||||
<t-input
|
||||
v-model.trim="scanCode"
|
||||
placeholder="输入或扫描二维码"
|
||||
borderless
|
||||
@blur="applyScanCode"
|
||||
:tips="scanResult"
|
||||
:status="scanStatus"
|
||||
>
|
||||
<template #suffix-icon>
|
||||
<t-button
|
||||
theme="primary"
|
||||
size="extra-small"
|
||||
@click="toggleScanner"
|
||||
>
|
||||
<span v-text="scannerVisible ? '关闭扫码' : '扫码'" />
|
||||
</t-button>
|
||||
</template>
|
||||
</t-input>
|
||||
</t-form-item>
|
||||
<div v-if="scannerVisible" class="code-scanner-wrapper">
|
||||
<code-scanner
|
||||
visible
|
||||
:status-text="scannerTips"
|
||||
active-text="正在识别客户编码,请将二维码移入取景框"
|
||||
@decode="handleDecodedText"
|
||||
@status-change="handleScanStatusChange"
|
||||
/>
|
||||
</div>
|
||||
<t-form-item label="客户姓名" required-mark>
|
||||
<t-input
|
||||
v-model.trim="customerName"
|
||||
placeholder="输入或选择已存档客户"
|
||||
borderless
|
||||
@blur="applyCustomerName"
|
||||
:tips="customerTips"
|
||||
:status="customerStatus"
|
||||
>
|
||||
<template #suffix-icon>
|
||||
<t-button
|
||||
theme="primary"
|
||||
size="extra-small"
|
||||
@click="selectorVisible = true"
|
||||
>选择</t-button>
|
||||
</template>
|
||||
</t-input>
|
||||
</t-form-item>
|
||||
<t-form-item label="客户照片">
|
||||
<photo-field
|
||||
v-model="customerPhoto"
|
||||
alt-text="客户照片"
|
||||
:loading="customerPhotoUploading"
|
||||
:preview="false"
|
||||
@add="openCustomerPhotoPicker"
|
||||
@clear="customerPhotoTempFileId = ''"
|
||||
/>
|
||||
</t-form-item>
|
||||
<t-form-item label-width="0">
|
||||
<event-selector
|
||||
v-model="selectedEventIdList"
|
||||
class="registration-event-selector"
|
||||
:event-list="eventList"
|
||||
/>
|
||||
</t-form-item>
|
||||
<t-form-item label="登记备注">
|
||||
<t-textarea
|
||||
v-model.trim="note"
|
||||
class="textarea"
|
||||
:autosize="{ minRows: 3, maxRows: 5 }"
|
||||
placeholder="现场说明"
|
||||
/>
|
||||
</t-form-item>
|
||||
<div class="photo-part">
|
||||
<photo-scroller
|
||||
title="登记事件照片"
|
||||
v-model="recordPhotoList"
|
||||
:loading="recordPhotoUploading"
|
||||
@add="openRecordPhotoPicker"
|
||||
/>
|
||||
</div>
|
||||
</t-form>
|
||||
<input
|
||||
ref="customerPhotoInputRef"
|
||||
class="file-input"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
@change="handleCustomerPhotoSelected"
|
||||
>
|
||||
<input
|
||||
ref="recordPhotoInputRef"
|
||||
class="file-input"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
multiple
|
||||
@change="handleRecordPhotoSelected"
|
||||
>
|
||||
<t-button theme="primary" class="save" :disabled="!canSubmit" :loading="saving" @click="saveRecord">提交</t-button>
|
||||
</div>
|
||||
<customer-selector
|
||||
v-model="selectedCustomerId"
|
||||
v-model:visible="selectorVisible"
|
||||
@change="handleCustomerChange"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { AttachmentAPI, Toolkit } from "timi-web";
|
||||
import type { TdInputProps } from "tdesign-mobile-vue";
|
||||
import { Toast } from "tdesign-mobile-vue";
|
||||
import CustomerAPI from "@/api/CustomerAPI";
|
||||
import EventAPI from "@/api/EventAPI";
|
||||
import CodeScanner from "@/components/CodeScanner.vue";
|
||||
import CustomerSelector from "@/components/CustomerSelector.vue";
|
||||
import PhotoField from "@/components/PhotoField.vue";
|
||||
import PhotoScroller from "@/components/PhotoScroller.vue";
|
||||
import { useSettingStore } from "@/store/settingStore";
|
||||
import type { Customer } from "@/types/Customer";
|
||||
import type { EventItem } from "@/types/EventItem";
|
||||
import { getCustomerPhotoURL } from "@/utils/customer";
|
||||
import { permissionCodes } from "@/utils/permission";
|
||||
import { compressUploadImage, compressUploadImageList } from "@/utils/imageCompression";
|
||||
import EventSelector from "@/components/EventSelector.vue";
|
||||
import EventRecordAPI from "@/api/EventRecordAPI.ts";
|
||||
|
||||
defineOptions({
|
||||
name: "QuickEntryPage"
|
||||
});
|
||||
|
||||
const settingStore = useSettingStore();
|
||||
const router = useRouter();
|
||||
|
||||
|
||||
const eventList = ref<EventItem[]>([]);
|
||||
|
||||
const selectedEventIdList = ref<string[]>([]);
|
||||
|
||||
const note = ref("");
|
||||
const saving = ref(false);
|
||||
const canSubmit = computed(() => !!inputCode.value && !!customerName.value.trim() && !customerPhotoUploading.value && !recordPhotoUploading.value);
|
||||
|
||||
let latestDecodedText = "";
|
||||
|
||||
const inputCode = computed(() => currentCode.value || scanCode.value || "");
|
||||
|
||||
type QuickPhoto = {
|
||||
tempFileId: string;
|
||||
url: string;
|
||||
};
|
||||
|
||||
// ---------- 扫码 ----------
|
||||
const scanCode = ref("");
|
||||
const currentCode = ref("");
|
||||
const scanResult = ref("");
|
||||
const scanStatus = ref<TdInputProps["status"]>("warning");
|
||||
const scannerVisible = ref(false);
|
||||
const scannerTips = ref("");
|
||||
const boundCodeCustomer = ref<Customer>();
|
||||
|
||||
async function toggleScanner(): Promise<void> {
|
||||
scannerVisible.value = !scannerVisible.value;
|
||||
if (scannerVisible.value) {
|
||||
latestDecodedText = "";
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDecodedText(rawText: string): Promise<void> {
|
||||
const normalizedText = rawText.trim();
|
||||
if (!normalizedText || normalizedText === latestDecodedText) {
|
||||
return;
|
||||
}
|
||||
latestDecodedText = normalizedText;
|
||||
scanCode.value = normalizedText;
|
||||
await settingStore.playScanSuccessSound();
|
||||
await applyScanCode();
|
||||
}
|
||||
|
||||
async function applyScanCode(): Promise<void> {
|
||||
const normalizedCode = scanCode.value.trim();
|
||||
if (!normalizedCode) {
|
||||
return;
|
||||
}
|
||||
currentCode.value = normalizedCode;
|
||||
await checkCustomerCode(normalizedCode);
|
||||
applyBoundCustomerIfNeeded();
|
||||
refreshInputTips();
|
||||
}
|
||||
|
||||
function handleScanStatusChange(value: string): void {
|
||||
scannerTips.value = value;
|
||||
}
|
||||
|
||||
// ---------- 补录客户 ----------
|
||||
const customerName = ref("");
|
||||
const currentCustomer = ref<Customer>();
|
||||
const customerTips = ref("");
|
||||
const customerStatus = ref<TdInputProps["status"]>("warning");
|
||||
const selectorVisible = ref(false);
|
||||
const selectedCustomerId = ref("");
|
||||
const checkedCode = ref("");
|
||||
|
||||
/**
|
||||
* 检验新客户编码
|
||||
*
|
||||
* @param code
|
||||
*/
|
||||
async function ensureNewCustomerCode(code: string): Promise<boolean> {
|
||||
await checkCustomerCode(code);
|
||||
applyBoundCustomerIfNeeded();
|
||||
refreshInputTips();
|
||||
return !isCodeBoundToOtherCustomer();
|
||||
}
|
||||
|
||||
// 检验编码
|
||||
async function ensureCodeChecked(): Promise<void> {
|
||||
const code = inputCode.value;
|
||||
if (!code || checkedCode.value === code) {
|
||||
return;
|
||||
}
|
||||
// 检验编码客户
|
||||
await checkCustomerCode(code);
|
||||
}
|
||||
|
||||
/**
|
||||
* 选择客户
|
||||
*
|
||||
* @param id 客户 ID
|
||||
*/
|
||||
async function handleCustomerChange(id: string): Promise<void> {
|
||||
const customer = await CustomerAPI.detail(id);
|
||||
currentCustomer.value = customer;
|
||||
selectedCustomerId.value = customer.id || "";
|
||||
customerName.value = customer.name || "";
|
||||
syncCustomerPhoto(customer);
|
||||
customerTips.value = "";
|
||||
if (!inputCode.value && customer.code) {
|
||||
// 补充编码
|
||||
fillCodeFromCustomer(customer);
|
||||
return;
|
||||
}
|
||||
if (inputCode.value) {
|
||||
await ensureCodeChecked();
|
||||
}
|
||||
refreshInputTips();
|
||||
}
|
||||
|
||||
async function applyCustomerName(): Promise<void> {
|
||||
const normalizedName = customerName.value.trim();
|
||||
if (!normalizedName) {
|
||||
currentCustomer.value = undefined;
|
||||
selectedCustomerId.value = "";
|
||||
customerTips.value = "";
|
||||
refreshInputTips();
|
||||
return;
|
||||
}
|
||||
const matched = await CustomerAPI.findByName(normalizedName) || undefined;
|
||||
if (!matched) {
|
||||
currentCustomer.value = undefined;
|
||||
selectedCustomerId.value = "";
|
||||
customerTips.value = "客户暂无档案,提交登记时会自动新开客户档案";
|
||||
refreshInputTips();
|
||||
return;
|
||||
}
|
||||
currentCustomer.value = matched;
|
||||
selectedCustomerId.value = matched.id || "";
|
||||
customerName.value = matched.name || normalizedName;
|
||||
syncCustomerPhoto(matched);
|
||||
customerTips.value = "已匹配客户";
|
||||
if (!inputCode.value && matched.code) {
|
||||
fillCodeFromCustomer(matched);
|
||||
return;
|
||||
}
|
||||
if (inputCode.value) {
|
||||
await ensureCodeChecked();
|
||||
}
|
||||
refreshInputTips();
|
||||
}
|
||||
|
||||
/**
|
||||
* 补充客户编码
|
||||
*
|
||||
* @param customer 客户
|
||||
*/
|
||||
function fillCodeFromCustomer(customer: Customer): void {
|
||||
const code = customer.code || "";
|
||||
if (!code) {
|
||||
// 未绑定编码
|
||||
refreshInputTips();
|
||||
return;
|
||||
}
|
||||
scanCode.value = code;
|
||||
currentCode.value = code;
|
||||
checkedCode.value = code;
|
||||
boundCodeCustomer.value = customer;
|
||||
refreshInputTips();
|
||||
}
|
||||
|
||||
/**
|
||||
* 检验编码
|
||||
*
|
||||
* @param code 编码
|
||||
*/
|
||||
async function checkCustomerCode(code: string): Promise<void> {
|
||||
const normalizedCode = code.trim();
|
||||
if (!normalizedCode) {
|
||||
checkedCode.value = "";
|
||||
boundCodeCustomer.value = undefined;
|
||||
refreshInputTips();
|
||||
return;
|
||||
}
|
||||
checkedCode.value = normalizedCode;
|
||||
boundCodeCustomer.value = await CustomerAPI.findByCode(normalizedCode) || undefined;
|
||||
scannerVisible.value = false;
|
||||
}
|
||||
|
||||
function applyBoundCustomerIfNeeded(): void {
|
||||
const boundCustomer = boundCodeCustomer.value;
|
||||
if (!boundCustomer?.id || currentCustomer.value?.id || customerName.value.trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
currentCustomer.value = boundCustomer;
|
||||
selectedCustomerId.value = boundCustomer.id;
|
||||
customerName.value = boundCustomer.name || "";
|
||||
syncCustomerPhoto(boundCustomer);
|
||||
customerTips.value = "";
|
||||
}
|
||||
|
||||
// 刷新提示
|
||||
function refreshInputTips(): void {
|
||||
scanStatus.value = "warning";
|
||||
customerStatus.value = "warning";
|
||||
const code = inputCode.value;
|
||||
if (!code) {
|
||||
scanResult.value = "";
|
||||
return;
|
||||
}
|
||||
if (checkedCode.value !== code) {
|
||||
scanResult.value = "";
|
||||
return;
|
||||
}
|
||||
if (isCodeBoundToOtherCustomer()) {
|
||||
scanResult.value = `编码已绑定客户 ${getCustomerName(boundCodeCustomer.value)},请使用新的编码`;
|
||||
return;
|
||||
}
|
||||
if (boundCodeCustomer.value?.id) {
|
||||
scanResult.value = "";
|
||||
return;
|
||||
}
|
||||
if (currentCustomer.value?.code && currentCustomer.value.code !== code) {
|
||||
scanResult.value = `客户已有编码 ${currentCustomer.value.code},将会替换新的编码`;
|
||||
scanStatus.value = "error";
|
||||
return;
|
||||
}
|
||||
scanResult.value = currentCustomer.value?.id ? "提交将会使用此编码" : "编码可用";
|
||||
}
|
||||
|
||||
function isCodeBoundToOtherCustomer(): boolean {
|
||||
const boundCustomerId = boundCodeCustomer.value?.id;
|
||||
if (!boundCustomerId) {
|
||||
return false;
|
||||
}
|
||||
return currentCustomer.value?.id !== boundCustomerId;
|
||||
}
|
||||
|
||||
function getCustomerName(customer?: Customer): string {
|
||||
return customer?.name || customer?.code || "未命名客户";
|
||||
}
|
||||
|
||||
// ---------- 客户照片 ----------
|
||||
const customerPhoto = ref("");
|
||||
const customerPhotoTempFileId = ref("");
|
||||
const customerPhotoInputRef = ref<HTMLInputElement>();
|
||||
const customerPhotoUploading = ref(false);
|
||||
|
||||
function openCustomerPhotoPicker(): void {
|
||||
customerPhotoInputRef.value?.click();
|
||||
}
|
||||
|
||||
function syncCustomerPhoto(customer?: Customer): void {
|
||||
customerPhotoTempFileId.value = "";
|
||||
customerPhoto.value = getCustomerPhotoURL(customer);
|
||||
}
|
||||
|
||||
async function handleCustomerPhotoSelected(event: Event): Promise<void> {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
customerPhotoUploading.value = true;
|
||||
try {
|
||||
const compressedFile = await compressUploadImage(file);
|
||||
const [tempFile] = await AttachmentAPI.upload(compressedFile);
|
||||
if (!tempFile?.id) {
|
||||
showToast("客户照片上传失败", "warning");
|
||||
return;
|
||||
}
|
||||
customerPhotoTempFileId.value = tempFile.id;
|
||||
customerPhoto.value = AttachmentAPI.getTempReadURL(tempFile.id);
|
||||
} finally {
|
||||
customerPhotoUploading.value = false;
|
||||
input.value = "";
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 记录照片 ----------
|
||||
const recordPhotoInputRef = ref<HTMLInputElement>();
|
||||
const recordPhotoUploading = ref(false);
|
||||
const recordPhotoItemList = ref<QuickPhoto[]>([]);
|
||||
|
||||
function openRecordPhotoPicker(): void {
|
||||
recordPhotoInputRef.value?.click();
|
||||
}
|
||||
const recordPhotoList = computed<string[]>({
|
||||
get() {
|
||||
return recordPhotoItemList.value.map(item => item.url);
|
||||
},
|
||||
set(value) {
|
||||
const urlSet = new Set(value);
|
||||
recordPhotoItemList.value = recordPhotoItemList.value.filter(item => urlSet.has(item.url));
|
||||
}
|
||||
});
|
||||
|
||||
async function handleRecordPhotoSelected(event: Event): Promise<void> {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const fileList = Array.from(input.files || []);
|
||||
if (!fileList.length) {
|
||||
return;
|
||||
}
|
||||
recordPhotoUploading.value = true;
|
||||
try {
|
||||
const compressedFileList = await compressUploadImageList(fileList);
|
||||
const tempFileList = await AttachmentAPI.upload(compressedFileList);
|
||||
for (let i = 0; i < tempFileList.length; i++) {
|
||||
const tempFile = tempFileList[i];
|
||||
if (!tempFile.id) {
|
||||
continue;
|
||||
}
|
||||
recordPhotoItemList.value.push({
|
||||
tempFileId: tempFile.id,
|
||||
url: AttachmentAPI.getTempReadURL(tempFile.id)
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
recordPhotoUploading.value = false;
|
||||
input.value = "";
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 提交 ----------
|
||||
|
||||
async function saveRecord(): Promise<void> {
|
||||
const customerCode = validateForm();
|
||||
if (!customerCode) {
|
||||
return;
|
||||
}
|
||||
saving.value = true;
|
||||
try {
|
||||
await ensureCustomerMatched();
|
||||
if (!await ensureNewCustomerCode(customerCode)) {
|
||||
return;
|
||||
}
|
||||
const customer: Customer = {
|
||||
id: currentCustomer.value?.id,
|
||||
code: customerCode,
|
||||
name: customerName.value.trim(),
|
||||
eventRecordList: selectedEventIdList.value.map(eventId => ({
|
||||
eventId,
|
||||
remark: note.value || "快速补录登记"
|
||||
}))
|
||||
};
|
||||
// 客户照片
|
||||
if (customerPhotoTempFileId.value) {
|
||||
customer.attachmentList = [{
|
||||
tempFileId: customerPhotoTempFileId.value,
|
||||
attachType: "PHOTO"
|
||||
}];
|
||||
}
|
||||
// 记录照片
|
||||
if (Toolkit.isNotEmpty(customer.eventRecordList)) {
|
||||
customer.eventRecordList![0].attachmentList = recordPhotoItemList.value.map(item => ({
|
||||
tempFileId: item.tempFileId,
|
||||
attachType: "PHOTO"
|
||||
}));
|
||||
}
|
||||
await EventRecordAPI.createWithCustomer(customer);
|
||||
resetFormAfterSubmit();
|
||||
await settingStore.playRegisterSuccessSound();
|
||||
showToast("登记已提交");
|
||||
await router.replace("/customers");
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureCustomerMatched(): Promise<void> {
|
||||
if (currentCustomer.value?.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
await applyCustomerName();
|
||||
}
|
||||
|
||||
function resetFormAfterSubmit(): void {
|
||||
note.value = "";
|
||||
scanCode.value = "";
|
||||
currentCode.value = "";
|
||||
customerName.value = "";
|
||||
selectedCustomerId.value = "";
|
||||
currentCustomer.value = undefined;
|
||||
boundCodeCustomer.value = undefined;
|
||||
customerPhoto.value = "";
|
||||
customerPhotoTempFileId.value = "";
|
||||
recordPhotoItemList.value = [];
|
||||
refreshInputTips();
|
||||
}
|
||||
|
||||
function validateForm(): string {
|
||||
const customerCode = (currentCode.value || scanCode.value).trim();
|
||||
if (!customerCode) {
|
||||
showToast("请先输入或扫码客户编码", "warning");
|
||||
return "";
|
||||
}
|
||||
if (!customerName.value.trim()) {
|
||||
showToast("请填写客户姓名", "warning");
|
||||
return "";
|
||||
}
|
||||
return customerCode;
|
||||
}
|
||||
|
||||
function showToast(message: string, theme: "success" | "warning" = "success"): void {
|
||||
Toast({
|
||||
theme,
|
||||
direction: "row",
|
||||
message
|
||||
});
|
||||
}
|
||||
|
||||
// ---------- 初始化 ----------
|
||||
|
||||
onMounted(async () => {
|
||||
eventList.value = await EventAPI.listValid();
|
||||
});
|
||||
|
||||
watch(scanCode, (value) => {
|
||||
const normalizedCode = value.trim();
|
||||
currentCode.value = normalizedCode;
|
||||
if (checkedCode.value && checkedCode.value !== normalizedCode) {
|
||||
checkedCode.value = "";
|
||||
boundCodeCustomer.value = undefined;
|
||||
}
|
||||
refreshInputTips();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.quick-entry-page {
|
||||
padding: calc(var(--app-nav-height) + 1rem) 0 4rem 0;
|
||||
|
||||
.form {
|
||||
padding: .5rem 0;
|
||||
background: rgba(255, 255, 255, .92);
|
||||
box-shadow: var(--td-shadow-1);
|
||||
border-radius: 1.4rem;
|
||||
|
||||
.code-scanner-wrapper,
|
||||
.photo-part {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.textarea {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.file-input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.empty-block {
|
||||
padding: 1rem;
|
||||
color: #52607a;
|
||||
text-align: center;
|
||||
background: #f8fbff;
|
||||
border-radius: 1rem;
|
||||
}
|
||||
|
||||
.save {
|
||||
left: 1rem;
|
||||
right: 1rem;
|
||||
bottom: calc(.75rem + env(safe-area-inset-bottom, 0));
|
||||
position: fixed;
|
||||
}
|
||||
}
|
||||
|
||||
:global(html.theme-dark) {
|
||||
.quick-entry-page {
|
||||
|
||||
:deep(.form-item .t-form__label) {
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.form {
|
||||
background: rgba(15, 23, 42, .88);
|
||||
box-shadow: 0 .75rem 2rem rgba(2, 6, 23, .32);
|
||||
}
|
||||
|
||||
.registration-event-selector {
|
||||
|
||||
:deep(.item),
|
||||
.empty-block {
|
||||
color: #e2e8f0;
|
||||
background: rgba(15, 23, 42, .72);
|
||||
border-color: rgba(148, 163, 184, .16);
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.textarea .t-textarea__inner) {
|
||||
color: #e2e8f0;
|
||||
background: rgba(15, 23, 42, .72);
|
||||
border-color: rgba(148, 163, 184, .16);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,364 +0,0 @@
|
||||
<template>
|
||||
<div v-can="permissionCodes.eventRecord.create" class="quick-sign-in-page">
|
||||
<button class="registration-event-select" type="button" @click="eventSelectorVisible = true">
|
||||
<span class="required-label">登记事件</span>
|
||||
<strong v-text="selectedEventName" />
|
||||
<t-icon name="chevron-right" />
|
||||
</button>
|
||||
<code-scanner
|
||||
visible
|
||||
:session-key="scannerSessionKey"
|
||||
:status-text="scanStatus"
|
||||
active-text="请将客户二维码移入取景框"
|
||||
@decode="handleDecodedText"
|
||||
@status-change="handleScanStatusChange"
|
||||
/>
|
||||
<customer-profile-card
|
||||
v-if="matchedCustomer"
|
||||
:customer="matchedCustomer"
|
||||
/>
|
||||
<t-cell-group
|
||||
v-if="recentRecordList.length"
|
||||
title="最近登记记录"
|
||||
theme="card"
|
||||
>
|
||||
<t-cell
|
||||
v-for="item in recentRecordList"
|
||||
:key="item.id"
|
||||
:title="item.event?.name || '未知事件'"
|
||||
:description="Time.toPassedDateTime(item.registeredAt)"
|
||||
:note="item.remark || '无备注'"
|
||||
arrow
|
||||
hover
|
||||
@click="toRecordDetail(item.id || '')"
|
||||
>
|
||||
<template #leftIcon>
|
||||
<t-image
|
||||
v-if="getEventRecordPhotoURL(item)"
|
||||
:src="getEventRecordPhotoURL(item)"
|
||||
:alt="item.event?.name || '未知事件'"
|
||||
fit="cover"
|
||||
class="record-photo"
|
||||
/>
|
||||
</template>
|
||||
</t-cell>
|
||||
</t-cell-group>
|
||||
<t-button
|
||||
class="submit"
|
||||
theme="primary"
|
||||
:disabled="!canSubmitRegister"
|
||||
:loading="submitting"
|
||||
@click="doSubmit"
|
||||
>登记</t-button>
|
||||
</div>
|
||||
<t-popup v-model="eventSelectorVisible" placement="bottom">
|
||||
<div class="registration-event-panel">
|
||||
<div class="title">登记事件</div>
|
||||
<t-cell-group v-if="eventList.length" theme="card">
|
||||
<t-cell
|
||||
v-for="item in eventList"
|
||||
:key="item.id"
|
||||
:title="item.name"
|
||||
:note="item.id === selectedEventId ? '当前使用' : ''"
|
||||
@click="selectEvent(item.id)"
|
||||
/>
|
||||
</t-cell-group>
|
||||
<div v-else class="empty-block">暂无可用登记事件</div>
|
||||
</div>
|
||||
</t-popup>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Toast } from "tdesign-mobile-vue";
|
||||
import CustomerAPI from "@/api/CustomerAPI";
|
||||
import EventAPI from "@/api/EventAPI";
|
||||
import EventRecordAPI from "@/api/EventRecordAPI";
|
||||
import CodeScanner from "@/components/CodeScanner.vue";
|
||||
import CustomerProfileCard from "@/components/CustomerProfileCard.vue";
|
||||
import { useSettingStore } from "@/store/settingStore";
|
||||
import type { Customer } from "@/types/Customer";
|
||||
import type { EventItem } from "@/types/EventItem";
|
||||
import type { EventRecord } from "@/types/EventRecord";
|
||||
import { permissionCodes } from "@/utils/permission";
|
||||
import { getEventRecordPhotoURL } from "@/utils/eventRecord";
|
||||
import { Storage, Time } from "timi-web";
|
||||
|
||||
defineOptions({
|
||||
name: "QuickSignInPage"
|
||||
});
|
||||
|
||||
const router = useRouter();
|
||||
const settingStore = useSettingStore();
|
||||
|
||||
// ---------- 登记事件 ----------
|
||||
|
||||
const EventItemStorageKey = "quick-scan-event-id";
|
||||
const eventSelectorVisible = ref(false);
|
||||
const selectedEventId = ref("");
|
||||
const eventList = ref<EventItem[]>([]);
|
||||
const selectedEventName = computed(() => eventList.value.find(item => item.id === selectedEventId.value)?.name || "请选择登记事件");
|
||||
|
||||
async function loadActions(): Promise<void> {
|
||||
eventList.value = await EventAPI.listValid();
|
||||
const storedEventId = Storage.getString(EventItemStorageKey);
|
||||
const storedAction = eventList.value.find(item => item.id === storedEventId);
|
||||
selectedEventId.value = storedAction?.id || eventList.value[0]?.id || "";
|
||||
if (selectedEventId.value) {
|
||||
Storage.setString(EventItemStorageKey, selectedEventId);
|
||||
}
|
||||
}
|
||||
|
||||
function selectEvent(eventId?: string): void {
|
||||
if (!eventId) {
|
||||
return;
|
||||
}
|
||||
selectedEventId.value = eventId;
|
||||
Storage.setString(EventItemStorageKey, selectedEventId);
|
||||
eventSelectorVisible.value = false;
|
||||
Toast.success("登记事件已切换");
|
||||
}
|
||||
|
||||
onMounted(loadActions);
|
||||
|
||||
// ---------- 扫码 ----------
|
||||
|
||||
const scanCode = ref("");
|
||||
const scanStatus = ref("正在启动扫码预览");
|
||||
const scannerSessionKey = ref(0);
|
||||
const matchedCustomer = ref<Customer>();
|
||||
let latestDecodedText = "";
|
||||
|
||||
async function handleDecodedText(rawText: string): Promise<void> {
|
||||
const normalizedText = rawText.trim();
|
||||
if (!normalizedText || normalizedText === latestDecodedText) {
|
||||
return;
|
||||
}
|
||||
latestDecodedText = normalizedText;
|
||||
scanCode.value = normalizedText;
|
||||
await settingStore.playScanSuccessSound();
|
||||
await loadCustomer(normalizedText);
|
||||
if (matchedCustomer.value) {
|
||||
scanStatus.value = `已识别 ${normalizedText},可直接登记`;
|
||||
return;
|
||||
}
|
||||
scanStatus.value = `已识别 ${normalizedText},但未匹配客户档案`;
|
||||
}
|
||||
|
||||
function handleScanStatusChange(value: string): void {
|
||||
scanStatus.value = value;
|
||||
}
|
||||
|
||||
// ---------- 读取客户 ----------
|
||||
|
||||
const recentRecordList = ref<EventRecord[]>([]);
|
||||
|
||||
async function loadCustomer(code: string): Promise<void> {
|
||||
const customer = await CustomerAPI.findByCode(code);
|
||||
if (!customer) {
|
||||
matchedCustomer.value = undefined;
|
||||
recentRecordList.value = [];
|
||||
return;
|
||||
}
|
||||
matchedCustomer.value = customer;
|
||||
recentRecordList.value = (await EventRecordAPI.list({
|
||||
index: 0,
|
||||
size: 5,
|
||||
customerId: matchedCustomer.value.id!
|
||||
})).list;
|
||||
}
|
||||
|
||||
async function toRecordDetail(id: string): Promise<void> {
|
||||
if (!id) {
|
||||
return;
|
||||
}
|
||||
await router.push(`/management/registration-event-records/${id}`);
|
||||
}
|
||||
|
||||
// ---------- 表单 ----------
|
||||
|
||||
const submitting = ref(false);
|
||||
const canSubmitRegister = computed(() => !!matchedCustomer.value && !!selectedEventId.value);
|
||||
|
||||
async function doSubmit(): Promise<void> {
|
||||
const customer = matchedCustomer.value;
|
||||
if (!customer) {
|
||||
return;
|
||||
}
|
||||
submitting.value = true;
|
||||
try {
|
||||
await EventRecordAPI.create({
|
||||
customerId: customer.id,
|
||||
eventId: selectedEventId.value,
|
||||
remark: "客户快速扫码登记"
|
||||
});
|
||||
scanStatus.value = `登记成功:${customer.name || customer.code || scanCode.value}。`;
|
||||
scanCode.value = "";
|
||||
matchedCustomer.value = undefined;
|
||||
recentRecordList.value = [];
|
||||
latestDecodedText = "";
|
||||
scannerSessionKey.value += 1;
|
||||
await settingStore.playRegisterSuccessSound();
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.quick-sign-in-page {
|
||||
gap: 1rem;
|
||||
padding: calc(var(--app-nav-height) + 1rem) 1rem 6rem 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.panel,
|
||||
.bottom-bar {
|
||||
left: 1rem;
|
||||
right: 1rem;
|
||||
}
|
||||
|
||||
.panel {
|
||||
padding: 1rem;
|
||||
background: rgba(255, 255, 255, .94);
|
||||
border-radius: 1.4rem;
|
||||
box-shadow: 0 .75rem 2rem rgba(15, 23, 42, .08);
|
||||
}
|
||||
|
||||
.registration-event-select {
|
||||
gap: .5rem;
|
||||
border: 0;
|
||||
padding: .875rem 1rem;
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
text-align: left;
|
||||
background: rgba(255, 255, 255, .94);
|
||||
box-shadow: 0 .75rem 2rem rgba(15, 23, 42, .08);
|
||||
align-items: center;
|
||||
border-radius: .875rem;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
|
||||
span {
|
||||
color: #52607a;
|
||||
font-size: .8125rem;
|
||||
}
|
||||
|
||||
strong {
|
||||
color: #0f172a;
|
||||
overflow: hidden;
|
||||
font-size: 1rem;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
}
|
||||
|
||||
.code-box {
|
||||
padding: .875rem;
|
||||
background: #f7faff;
|
||||
border: 1px solid rgba(148, 163, 184, .2);
|
||||
border-radius: 1rem;
|
||||
}
|
||||
|
||||
.required-label::before {
|
||||
color: #e34d59;
|
||||
content: "*";
|
||||
margin-right: .25rem;
|
||||
}
|
||||
|
||||
.code-box {
|
||||
display: grid;
|
||||
margin-top: .875rem;
|
||||
gap: .375rem;
|
||||
|
||||
span {
|
||||
color: #52607a;
|
||||
font-size: .8125rem;
|
||||
}
|
||||
|
||||
strong {
|
||||
font-size: 1.125rem;
|
||||
line-height: 1.5;
|
||||
word-break: break-all;
|
||||
}
|
||||
}
|
||||
|
||||
.submit {
|
||||
left: 1rem;
|
||||
right: 1rem;
|
||||
bottom: calc(.75rem + env(safe-area-inset-bottom, 0));
|
||||
position: fixed;
|
||||
}
|
||||
|
||||
.record-photo {
|
||||
width: 3.5rem;
|
||||
height: 3.5rem;
|
||||
border-radius: .875rem;
|
||||
}
|
||||
}
|
||||
|
||||
.registration-event-panel {
|
||||
gap: .875rem;
|
||||
padding: 1rem 0 calc(1rem + env(safe-area-inset-bottom, 0)) 0;
|
||||
display: flex;
|
||||
background: #f5f5f8;
|
||||
border-radius: 1rem 1rem 0 0;
|
||||
flex-direction: column;
|
||||
|
||||
.title {
|
||||
margin: 0;
|
||||
color: #0f172a;
|
||||
font-size: 1rem;
|
||||
text-align: center;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.empty-block {
|
||||
color: #52607a;
|
||||
padding: 1rem;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
:global(html.theme-dark) {
|
||||
.quick-sign-in-page {
|
||||
.panel,
|
||||
.bottom-bar {
|
||||
background: rgba(15, 23, 42, .88);
|
||||
box-shadow: 0 .75rem 2rem rgba(2, 6, 23, .32);
|
||||
}
|
||||
|
||||
.registration-event-select {
|
||||
background: rgba(15, 23, 42, .88);
|
||||
box-shadow: 0 .75rem 2rem rgba(2, 6, 23, .32);
|
||||
|
||||
span {
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
strong {
|
||||
color: #e2e8f0;
|
||||
}
|
||||
}
|
||||
|
||||
.code-box {
|
||||
background: rgba(30, 41, 59, .9);
|
||||
border-color: rgba(148, 163, 184, .16);
|
||||
}
|
||||
|
||||
.code-box span {
|
||||
color: #94a3b8;
|
||||
}
|
||||
}
|
||||
|
||||
.registration-event-panel {
|
||||
background: #10161f;
|
||||
|
||||
.title {
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
.empty-block {
|
||||
color: #94a3b8;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user