init project
This commit is contained in:
@@ -0,0 +1,670 @@
|
||||
<template>
|
||||
<div v-can="gaoPermissionCodes.stat.read" class="customer-data-charts-page">
|
||||
<div class="selector">
|
||||
<div class="header">
|
||||
<t-button class="list-select" 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 class="scan-select" theme="primary" @click="toggleScanner">
|
||||
<template #icon>
|
||||
<t-icon name="scan" />
|
||||
</template>
|
||||
</t-button>
|
||||
</div>
|
||||
<div v-if="scannerVisible" class="scanner-wrapper">
|
||||
<code-scanner
|
||||
visible
|
||||
:status-text="scanStatus"
|
||||
active-text="正在识别客户编码,请将二维码移入取景框"
|
||||
@decode="handleDecodedText"
|
||||
@status-change="handleScanStatusChange"
|
||||
/>
|
||||
</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>
|
||||
<registration-event-record-calendar
|
||||
v-if="selectedCustomer"
|
||||
v-model="monthCursor"
|
||||
v-model:selected-registration-event-id-list="selectedRegistrationEventIdList"
|
||||
class="calendar-section"
|
||||
:registration-event-list="registrationEventList"
|
||||
: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>
|
||||
<div v-if="!selectedCustomer" class="empty-block">请选择客户后查看专属图表</div>
|
||||
<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 CustomerAPI from "@/api/CustomerAPI";
|
||||
import RegistrationEventAPI from "@/api/RegistrationEventAPI";
|
||||
import RegistrationEventRecordAPI from "@/api/RegistrationEventRecordAPI";
|
||||
import type {Customer} from "@/types/Customer";
|
||||
import type {RegistrationEvent} from "@/types/RegistrationEvent";
|
||||
import type {RegistrationEventRecord} from "@/types/RegistrationEventRecord";
|
||||
import CodeScanner from "@/components/CodeScanner.vue";
|
||||
import CustomerSelector from "@/components/CustomerSelector.vue";
|
||||
import RegistrationEventRecordCalendar from "@/components/RegistrationEventRecordCalendar.vue";
|
||||
import {useSettingStore} from "@/store/settingStore";
|
||||
import {Time} from "timi-web";
|
||||
import {gaoPermissionCodes} from "@/utils/gaoPermission";
|
||||
|
||||
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 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 registrationEventList = ref<RegistrationEvent[]>([]);
|
||||
const selectedRegistrationEventIdList = ref<string[]>([]);
|
||||
const monthCursor = ref(getMonthStart(new Date()));
|
||||
const monthRecords = ref<RegistrationEventRecord[]>([]);
|
||||
const calendarLoading = ref(false);
|
||||
let latestDecodedText = "";
|
||||
|
||||
const registrationEventNameMap = computed(() => {
|
||||
const map = new Map<string, string>();
|
||||
for (const item of registrationEventList.value) {
|
||||
if (item.id) {
|
||||
map.set(item.id, item.name || "未命名事件");
|
||||
}
|
||||
}
|
||||
return map;
|
||||
});
|
||||
const selectedRegistrationEventIdSet = computed(() => new Set(selectedRegistrationEventIdList.value));
|
||||
const filteredMonthRecords = computed(() => {
|
||||
if (!selectedRegistrationEventIdList.value.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return monthRecords.value.filter(item => {
|
||||
if (!item.registrationEventId || !selectedRegistrationEventIdSet.value.has(item.registrationEventId)) {
|
||||
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: getRecordRegistrationEventName(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> {
|
||||
registrationEventList.value = await RegistrationEventAPI.enabledList();
|
||||
selectedRegistrationEventIdList.value = registrationEventList.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.nullableDetailByCode(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 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 RegistrationEventRecordAPI.rangeList({
|
||||
customerId,
|
||||
beginRegisteredAt: Time.toTimestamp(beginDate),
|
||||
endRegisteredAt: Time.toTimestamp(endDate)
|
||||
});
|
||||
} 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: RegistrationEventRecord): string {
|
||||
const date = Time.toDateObject(record.registeredAt);
|
||||
return date ? Time.formatDate(date) : "";
|
||||
}
|
||||
|
||||
function getRecordRegistrationEventName(record?: RegistrationEventRecord): string {
|
||||
if (!record) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return record.registrationEvent?.name || (record.registrationEventId ? registrationEventNameMap.value.get(record.registrationEventId) : "") || "未知事件";
|
||||
}
|
||||
|
||||
// ---------- 图表 ----------
|
||||
|
||||
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: registrationEventList.value.filter(item => item.id && selectedRegistrationEventIdSet.value.has(item.id)).map(item => item.name || "未命名事件"),
|
||||
axisTick: {
|
||||
show: false
|
||||
},
|
||||
axisLabel: {
|
||||
color: "#52607a"
|
||||
}
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: "登记次数",
|
||||
type: "bar",
|
||||
data: registrationEventList.value
|
||||
.filter(item => item.id && selectedRegistrationEventIdSet.value.has(item.id))
|
||||
.map(item => ({
|
||||
value: filteredMonthRecords.value.filter(record => record.registrationEventId === item.id).length,
|
||||
itemStyle: getRegistrationEventColor(item) ? {
|
||||
color: getRegistrationEventColor(item)
|
||||
} : undefined
|
||||
}))
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
function getRegistrationEventColor(registrationEvent: RegistrationEvent): string | undefined {
|
||||
return registrationEvent.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;
|
||||
|
||||
.selector {
|
||||
gap: .75rem;
|
||||
top: var(--app-nav-height);
|
||||
z-index: 10;
|
||||
padding: 1rem 1rem .875rem 1rem;
|
||||
display: flex;
|
||||
position: sticky;
|
||||
box-shadow: var(--td-shadow-1);
|
||||
background: #f5f5f8;
|
||||
flex-direction: column;
|
||||
|
||||
.header {
|
||||
gap: .75rem;
|
||||
display: flex;
|
||||
|
||||
.list-select {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.scan-select {
|
||||
flex: 0 0 3rem;
|
||||
width: 3rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
}
|
||||
|
||||
.empty-block {
|
||||
padding: 1rem;
|
||||
color: #52607a;
|
||||
margin: 0 1rem;
|
||||
text-align: center;
|
||||
background: #f7faff;
|
||||
border-radius: .75rem;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
: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;
|
||||
}
|
||||
}
|
||||
|
||||
.empty-block {
|
||||
color: #94a3b8;
|
||||
background: rgba(30, 41, 59, .9);
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,564 @@
|
||||
<template>
|
||||
<div v-can="gaoPermissionCodes.customer.read" class="customer-detail">
|
||||
<customer-profile-card
|
||||
class="customer"
|
||||
:customer="customer"
|
||||
/>
|
||||
<t-cell-group v-if="customer" theme="card">
|
||||
<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="goCustomerDataCharts">
|
||||
<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" theme="card">
|
||||
<t-cell
|
||||
:title="inviter.name"
|
||||
:description="inviter.telephone || '未填写电话'"
|
||||
arrow
|
||||
@click="goCustomerDetail(inviter)"
|
||||
>
|
||||
<template #leftIcon>
|
||||
<t-image
|
||||
v-if="getCustomerPhotoURL(inviter)"
|
||||
class="avatar"
|
||||
:src="getCustomerPhotoURL(inviter)"
|
||||
:alt="inviter.name"
|
||||
fit="cover"
|
||||
/>
|
||||
<div v-else class="avatar fallback" v-text="(inviter.name || '客').slice(0, 1)" />
|
||||
</template>
|
||||
</t-cell>
|
||||
</t-cell-group>
|
||||
<div class="invite">
|
||||
<div class="header">
|
||||
<div class="title">邀请客户</div>
|
||||
<div v-can="gaoPermissionCodes.customer.update" class="actions">
|
||||
<t-button
|
||||
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 in invitees"
|
||||
:key="item.id || item.code"
|
||||
>
|
||||
<t-cell
|
||||
:description="item.telephone || '未填写电话'"
|
||||
arrow
|
||||
hover
|
||||
@click="goCustomerDetail(item)"
|
||||
>
|
||||
<template #title>
|
||||
<div class="title">
|
||||
<span v-text="item.name || item.code" />
|
||||
</div>
|
||||
</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="gaoPermissionCodes.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="gaoPermissionCodes.customer.delete"
|
||||
class="delete bg-white"
|
||||
theme="danger"
|
||||
variant="outline"
|
||||
:loading="removing"
|
||||
@click="confirmRemoveCustomer"
|
||||
>
|
||||
删除客户
|
||||
</t-button>
|
||||
<t-button v-can="gaoPermissionCodes.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 { gaoPermissionCodes } from "@/utils/gaoPermission";
|
||||
|
||||
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 removing = ref(false);
|
||||
let latestDecodedText = "";
|
||||
|
||||
const inviter = computed(() => customer.value?.introducerCustomer);
|
||||
const canReadStat = computed(() => userStore.hasPermissions(gaoPermissionCodes.stat.read));
|
||||
const creatorName = computed(() => {
|
||||
const creatorUser = customer.value?.creatorUser;
|
||||
return creatorUser?.nick || creatorUser?.name || customer.value?.creatorUserId || "无";
|
||||
});
|
||||
const createdAtText = computed(() => formatTime(customer.value?.createdAt));
|
||||
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 = [];
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
customer.value = await CustomerAPI.detail(id);
|
||||
await loadInvitees();
|
||||
} catch (err) {
|
||||
customer.value = undefined;
|
||||
invitees.value = [];
|
||||
console.warn("客户详情加载失败", err);
|
||||
showToast("客户详情加载失败", "warning");
|
||||
}
|
||||
}
|
||||
|
||||
async function loadInvitees(): Promise<void> {
|
||||
const customerId = customer.value?.id;
|
||||
if (!customerId) {
|
||||
invitees.value = [];
|
||||
return;
|
||||
}
|
||||
|
||||
invitees.value = await CustomerAPI.invitedList(customerId);
|
||||
}
|
||||
|
||||
async function removeInvitee(item: Customer): Promise<void> {
|
||||
if (!item.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
await CustomerAPI.deleteInvite(item.id);
|
||||
await loadCustomer();
|
||||
showToast("已删除邀请关系");
|
||||
}
|
||||
|
||||
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> {
|
||||
removing.value = true;
|
||||
try {
|
||||
await CustomerAPI.remove(id);
|
||||
showToast("客户已删除");
|
||||
await router.replace("/management/customers");
|
||||
} finally {
|
||||
removing.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function toEdit(): Promise<void> {
|
||||
if (!customer.value?.id) {
|
||||
return;
|
||||
}
|
||||
await router.push(`/management/customers/${customer.value.id}/edit`);
|
||||
}
|
||||
|
||||
function showToast(message: string, theme: "success" | "warning" = "success"): void {
|
||||
Toast({
|
||||
theme,
|
||||
direction: "row",
|
||||
message
|
||||
});
|
||||
}
|
||||
|
||||
function formatTime(value?: number): string {
|
||||
if (!value) {
|
||||
return "无";
|
||||
}
|
||||
const date = Time.toDateObject(value);
|
||||
if (!date) {
|
||||
return "无";
|
||||
}
|
||||
return Time.formatDateTime(date);
|
||||
}
|
||||
|
||||
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) {
|
||||
showToast("不能把客户本人添加为邀请客户", "warning");
|
||||
return false;
|
||||
}
|
||||
if (isCustomerAncestor(invitee)) {
|
||||
showToast("不能把上级邀请人添加为邀请客户", "warning");
|
||||
return false;
|
||||
}
|
||||
|
||||
await CustomerAPI.updateInvite({
|
||||
customerId: invitee.id,
|
||||
introducerCustomerId: customer.value.id
|
||||
});
|
||||
await loadCustomer();
|
||||
showToast(`已添加 ${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) {
|
||||
showToast("请先扫码识别客户", "warning");
|
||||
return;
|
||||
}
|
||||
|
||||
if (await addInvitee(pendingInvitee.value)) {
|
||||
closeScanPopup();
|
||||
}
|
||||
}
|
||||
|
||||
async function loadCustomerByCode(code: string): Promise<Customer | undefined> {
|
||||
return await CustomerAPI.nullableDetailByCode(code) || undefined;
|
||||
}
|
||||
|
||||
async function goCustomerDetail(target: Customer): Promise<void> {
|
||||
if (!target.id) {
|
||||
return;
|
||||
}
|
||||
await router.push(`/management/customers/${target.id}`);
|
||||
}
|
||||
|
||||
async function goCustomerDataCharts(): 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;
|
||||
}
|
||||
|
||||
.conditioning-part {
|
||||
gap: .5rem;
|
||||
padding: 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.invite {
|
||||
|
||||
.item {
|
||||
|
||||
.title {
|
||||
gap: .5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
|
||||
:deep(.t-cell:after) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
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;
|
||||
}
|
||||
|
||||
.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: 1rem;
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.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,541 @@
|
||||
<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="formModel.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>
|
||||
<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="formModel.name"
|
||||
placeholder="姓名"
|
||||
borderless
|
||||
/>
|
||||
</t-form-item>
|
||||
<t-form-item label="年龄">
|
||||
<t-input
|
||||
v-model.trim="formModel.age"
|
||||
type="number"
|
||||
placeholder="年龄"
|
||||
borderless
|
||||
/>
|
||||
</t-form-item>
|
||||
<t-form-item label="照片">
|
||||
<photo-field
|
||||
v-model="formModel.photoURL"
|
||||
alt-text="客户照片"
|
||||
:preview="false"
|
||||
@add="selectPhoto"
|
||||
@clear="resetPhotoState"
|
||||
/>
|
||||
</t-form-item>
|
||||
<t-form-item label="性别">
|
||||
<t-radio-group class="sex" v-model="formModel.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="formModel.telephone" placeholder="手机号" borderless />
|
||||
</t-form-item>
|
||||
<t-form-item label="介绍人">
|
||||
<div class="referrer">
|
||||
<t-input
|
||||
v-model.trim="formModel.referrer"
|
||||
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('referrer')">
|
||||
<span v-text="scannerVisible && scannerTarget === 'referrer' ? '关闭扫码' : '扫码'" />
|
||||
</t-button>
|
||||
</div>
|
||||
</div>
|
||||
</t-form-item>
|
||||
<div v-if="scannerVisible && scannerTarget === 'referrer'" 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="formModel.address"
|
||||
:autosize="{ minRows: 3, maxRows: 5 }"
|
||||
placeholder="住址"
|
||||
/>
|
||||
</t-form-item>
|
||||
<t-form-item label="病症">
|
||||
<t-textarea
|
||||
class="textarea"
|
||||
v-model.trim="formModel.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="formModel.note"
|
||||
: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="saving"
|
||||
@click="saveCustomer"
|
||||
>
|
||||
<template #icon>
|
||||
<t-icon name="check" />
|
||||
</template>
|
||||
保存
|
||||
</t-button>
|
||||
<customer-selector
|
||||
v-model="referrerCustomerId"
|
||||
v-model:visible="selectorVisible"
|
||||
@change="handleReferrerChange"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { AttachmentAPI } 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 { useSettingStore } from "@/store/settingStore";
|
||||
import type { BodyAreaValue, Customer, CustomerGender } from "@/types/Customer";
|
||||
import { createBodyAreaValue, getCustomerPhotoURL, normalizeBodyAreaValue } from "@/utils/customer";
|
||||
import { gaoPermissionCodes } from "@/utils/gaoPermission";
|
||||
import { compressUploadImage } from "@/utils/imageCompression";
|
||||
|
||||
defineOptions({
|
||||
name: "CustomerEdit"
|
||||
});
|
||||
|
||||
type ScanTarget = "code" | "referrer";
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const settingStore = useSettingStore();
|
||||
const isCreateMode = computed(() => route.name === "CustomerCreate");
|
||||
const savePermissionCode = computed(() => isCreateMode.value ? gaoPermissionCodes.customer.create : gaoPermissionCodes.customer.update);
|
||||
const selectedAreas = ref<BodyAreaValue>(createBodyAreaValue());
|
||||
const selectorVisible = ref(false);
|
||||
const referrerCustomerId = ref("");
|
||||
const photoInputRef = ref<HTMLInputElement>();
|
||||
const photoTempFileId = ref("");
|
||||
const scannerVisible = ref(false);
|
||||
const scannerTarget = ref<ScanTarget>("code");
|
||||
const scanStatus = ref("点击扫码开始识别。");
|
||||
const currentCustomer = ref<Customer>();
|
||||
const saving = ref(false);
|
||||
const canSubmit = computed(() => !!formModel.name.trim());
|
||||
let latestDecodedText = "";
|
||||
|
||||
const formModel = reactive({
|
||||
code: "",
|
||||
name: "",
|
||||
gender: "" as CustomerGender | "",
|
||||
photoURL: "",
|
||||
telephone: "",
|
||||
age: "",
|
||||
address: "",
|
||||
referrer: "",
|
||||
disease: "",
|
||||
inviterCustomerId: "",
|
||||
note: ""
|
||||
});
|
||||
|
||||
function resetFormModel(): void {
|
||||
Object.assign(formModel, {
|
||||
code: "",
|
||||
name: "",
|
||||
gender: "",
|
||||
photoURL: "",
|
||||
telephone: "",
|
||||
age: "",
|
||||
address: "",
|
||||
referrer: "",
|
||||
disease: "",
|
||||
inviterCustomerId: "",
|
||||
note: ""
|
||||
});
|
||||
currentCustomer.value = undefined;
|
||||
selectedAreas.value = createBodyAreaValue();
|
||||
referrerCustomerId.value = "";
|
||||
photoTempFileId.value = "";
|
||||
scannerVisible.value = false;
|
||||
}
|
||||
|
||||
async function fillCreateInviter(): Promise<void> {
|
||||
const inviterCode = String(route.query.inviterCode || "");
|
||||
if (!inviterCode) {
|
||||
return;
|
||||
}
|
||||
|
||||
const inviter = await findCustomer(inviterCode);
|
||||
if (!inviter) {
|
||||
return;
|
||||
}
|
||||
formModel.referrer = inviter.name || inviter.code || "";
|
||||
formModel.inviterCustomerId = inviter.id || "";
|
||||
referrerCustomerId.value = inviter.id || "";
|
||||
}
|
||||
|
||||
async function fillForm(): Promise<void> {
|
||||
resetFormModel();
|
||||
if (isCreateMode.value) {
|
||||
await fillCreateInviter();
|
||||
return;
|
||||
}
|
||||
|
||||
const id = String(route.params.id || "");
|
||||
if (!id) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const customer = await CustomerAPI.detail(id);
|
||||
currentCustomer.value = customer;
|
||||
Object.assign(formModel, {
|
||||
code: customer.code || "",
|
||||
name: customer.name || "",
|
||||
gender: customer.gender || "",
|
||||
photoURL: getCustomerPhotoURL(customer),
|
||||
telephone: customer.telephone || "",
|
||||
age: customer.age === undefined || customer.age === null ? "" : String(customer.age),
|
||||
address: customer.address || "",
|
||||
referrer: customer.introducerCustomer?.name || "",
|
||||
disease: customer.disease || "",
|
||||
inviterCustomerId: customer.introducerCustomerId || "",
|
||||
note: customer.remark || ""
|
||||
});
|
||||
selectedAreas.value = normalizeBodyAreaValue(customer.conditioningPart);
|
||||
referrerCustomerId.value = customer.introducerCustomer?.id || "";
|
||||
} catch (err) {
|
||||
console.warn("客户详情加载失败", err);
|
||||
showToast("客户详情加载失败", "warning");
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 扫码 ----------
|
||||
|
||||
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") {
|
||||
formModel.code = normalizedText;
|
||||
scanStatus.value = `已识别客户编码 ${normalizedText}。`;
|
||||
scannerVisible.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
await applyReferrer(normalizedText);
|
||||
}
|
||||
|
||||
function handleScanStatusChange(value: string): void {
|
||||
scanStatus.value = value;
|
||||
}
|
||||
|
||||
// ---------- 照片 ----------
|
||||
|
||||
function selectPhoto(): void {
|
||||
photoInputRef.value?.click();
|
||||
}
|
||||
|
||||
function resetPhotoState(): void {
|
||||
photoTempFileId.value = "";
|
||||
}
|
||||
|
||||
async function handlePhotoSelected(event: Event): Promise<void> {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
const compressedFile = await compressUploadImage(file);
|
||||
const [tempFile] = await AttachmentAPI.uploadTemp(compressedFile);
|
||||
if (!tempFile?.id) {
|
||||
showToast("客户照片上传失败", "warning");
|
||||
return;
|
||||
}
|
||||
photoTempFileId.value = tempFile.id;
|
||||
formModel.photoURL = AttachmentAPI.getTempReadURL(tempFile.id);
|
||||
input.value = "";
|
||||
}
|
||||
|
||||
// ---------- 介绍人 ----------
|
||||
|
||||
async function findCustomer(rawValue: string): Promise<Customer | undefined> {
|
||||
const normalizedText = rawValue.trim();
|
||||
if (!normalizedText) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const customer = await CustomerAPI.nullableDetailByCode(normalizedText);
|
||||
if (customer) {
|
||||
return customer;
|
||||
}
|
||||
|
||||
const list = await CustomerAPI.search({ keyword: normalizedText });
|
||||
return list[0];
|
||||
}
|
||||
|
||||
async function applyReferrer(rawValue: string): Promise<void> {
|
||||
const normalizedText = rawValue.trim();
|
||||
if (!normalizedText) {
|
||||
formModel.referrer = "";
|
||||
formModel.inviterCustomerId = "";
|
||||
referrerCustomerId.value = "";
|
||||
scannerVisible.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const matched = await findCustomer(normalizedText);
|
||||
if (!matched?.id) {
|
||||
formModel.referrer = "";
|
||||
formModel.inviterCustomerId = "";
|
||||
referrerCustomerId.value = "";
|
||||
scanStatus.value = `未匹配到介绍人 ${normalizedText},请重新选择或扫码。`;
|
||||
scannerVisible.value = false;
|
||||
showToast("介绍人不存在,请选择或扫码已有客户", "warning");
|
||||
return;
|
||||
}
|
||||
|
||||
formModel.referrer = matched.name || matched.code || "";
|
||||
formModel.inviterCustomerId = matched.id;
|
||||
referrerCustomerId.value = matched.id;
|
||||
scanStatus.value = `已匹配介绍人 ${formModel.referrer}。`;
|
||||
scannerVisible.value = false;
|
||||
}
|
||||
|
||||
async function handleReferrerChange(id: string): Promise<void> {
|
||||
const customer = await CustomerAPI.detail(id);
|
||||
formModel.referrer = customer.name || customer.code || "";
|
||||
formModel.inviterCustomerId = customer.id || "";
|
||||
referrerCustomerId.value = customer.id || "";
|
||||
scanStatus.value = `已选择介绍人 ${formModel.referrer}。`;
|
||||
}
|
||||
|
||||
// ---------- 保存 ----------
|
||||
|
||||
async function saveCustomer(): Promise<void> {
|
||||
if (!validateForm()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const customerCode = formModel.code.trim();
|
||||
const payload: Customer = {
|
||||
...(currentCustomer.value || {}),
|
||||
code: customerCode || undefined,
|
||||
name: formModel.name,
|
||||
gender: formModel.gender || undefined,
|
||||
age: Number(formModel.age) || undefined,
|
||||
telephone: formModel.telephone,
|
||||
address: formModel.address,
|
||||
remark: formModel.note,
|
||||
disease: formModel.disease,
|
||||
conditioningPart: selectedAreas.value,
|
||||
introducerCustomerId: formModel.inviterCustomerId || undefined,
|
||||
attachmentIdList: photoTempFileId.value ? [photoTempFileId.value] : undefined
|
||||
};
|
||||
|
||||
saving.value = true;
|
||||
try {
|
||||
let savedCustomer = currentCustomer.value;
|
||||
if (isCreateMode.value) {
|
||||
savedCustomer = await CustomerAPI.create(payload);
|
||||
} else {
|
||||
await CustomerAPI.update(payload);
|
||||
}
|
||||
showToast("客户已保存");
|
||||
const redirect = String(route.query.redirect || "");
|
||||
if (redirect) {
|
||||
await router.replace(redirect);
|
||||
return;
|
||||
}
|
||||
if (savedCustomer?.id) {
|
||||
await router.replace(`/management/customers/${savedCustomer.id}`);
|
||||
return;
|
||||
}
|
||||
await router.replace("/management/customers");
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function validateForm(): boolean {
|
||||
if (!formModel.name.trim()) {
|
||||
showToast("请填写客户姓名", "warning");
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function showToast(message: string, theme: "success" | "warning" = "success"): void {
|
||||
Toast({
|
||||
theme,
|
||||
direction: "row",
|
||||
message
|
||||
});
|
||||
}
|
||||
|
||||
watch(() => formModel.referrer, (value) => {
|
||||
if (!value.trim()) {
|
||||
formModel.inviterCustomerId = "";
|
||||
referrerCustomerId.value = "";
|
||||
}
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
await fillForm();
|
||||
});
|
||||
|
||||
watch(() => route.fullPath, async () => {
|
||||
await fillForm();
|
||||
});
|
||||
</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,374 @@
|
||||
<template>
|
||||
<div ref="pageRef" v-can="gaoPermissionCodes.customer.read" class="customer-list">
|
||||
<div class="search-bar">
|
||||
<t-input
|
||||
v-model.trim="keyword"
|
||||
class="text-input"
|
||||
placeholder="搜索编码、姓名、电话"
|
||||
clearable
|
||||
borderless
|
||||
/>
|
||||
</div>
|
||||
<section class="total">
|
||||
<span class="gray">客户总数:</span>
|
||||
<span class="bold" v-text="String(totalCount)" />
|
||||
</section>
|
||||
<section class="items">
|
||||
<div
|
||||
class="item"
|
||||
v-for="item in customerList"
|
||||
:key="item.id || item.code"
|
||||
@click="goDetail(item)"
|
||||
>
|
||||
<t-image v-if="getCustomerPhotoURL(item)" :src="getCustomerPhotoURL(item)" :alt="item.name" fit="cover" class="avatar" />
|
||||
<div v-else class="avatar fallback" v-text="(item.name || item.code || '客').slice(0, 1)" />
|
||||
<div class="content">
|
||||
<div class="line">
|
||||
<div class="name">
|
||||
<h4 class="value" v-text="item.name" />
|
||||
<gender-icon :value="item.gender" size="1.125rem" />
|
||||
</div>
|
||||
<span class="meta gray" v-text="item.code" />
|
||||
</div>
|
||||
<div class="line">
|
||||
<div class="gray">
|
||||
<template v-if="item.telephone">
|
||||
<t-icon name="call" />
|
||||
<span class="word-space" v-text="item.telephone" />
|
||||
</template>
|
||||
</div>
|
||||
<t-tag
|
||||
size="small"
|
||||
variant="outline"
|
||||
theme="success"
|
||||
:content="`${item.invitedCount} 位邀请`"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!customerList.length" class="empty-block" v-text="loading ? '正在加载客户' : '暂无客户数据'" />
|
||||
<div v-if="customerList.length" class="load-state" v-text="loading ? '正在加载更多' : noMore ? '已经到底了' : '上拉加载更多'" />
|
||||
</section>
|
||||
<div class="bottom-actions">
|
||||
<t-button
|
||||
v-can="gaoPermissionCodes.customer.read"
|
||||
theme="primary"
|
||||
variant="outline"
|
||||
class="export bg-white"
|
||||
:loading="exporting"
|
||||
:disabled="exporting"
|
||||
@click="exportCustomers"
|
||||
>
|
||||
<template #icon>
|
||||
<t-icon name="download" />
|
||||
</template>
|
||||
导出
|
||||
</t-button>
|
||||
<t-button
|
||||
v-can="gaoPermissionCodes.customer.create"
|
||||
theme="primary"
|
||||
class="create"
|
||||
@click="router.push('/management/customers/create')"
|
||||
>
|
||||
<template #icon>
|
||||
<t-icon name="add" />
|
||||
</template>
|
||||
添加
|
||||
</t-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Toast } from "tdesign-mobile-vue";
|
||||
import { Scroller, Time, Toolkit } from "timi-web";
|
||||
import CustomerAPI from "@/api/CustomerAPI";
|
||||
import GenderIcon from "@/components/GenderIcon.vue";
|
||||
import type { Customer, CustomerSearchReq } from "@/types/Customer";
|
||||
import { getCustomerPhotoURL } from "@/utils/customer";
|
||||
import { gaoPermissionCodes } from "@/utils/gaoPermission";
|
||||
|
||||
defineOptions({
|
||||
name: "CustomerList"
|
||||
});
|
||||
|
||||
const scrollListenerName = "CustomerList";
|
||||
const router = useRouter();
|
||||
const pageSize = 20;
|
||||
const keyword = ref("");
|
||||
const loading = ref(false);
|
||||
const exporting = ref(false);
|
||||
const noMore = ref(false);
|
||||
const pageIndex = ref(0);
|
||||
const totalCount = ref(0);
|
||||
const customerList = ref<Customer[]>([]);
|
||||
const pageRef = ref<HTMLElement>();
|
||||
let loadToken = 0;
|
||||
let keywordTimer: number | undefined;
|
||||
|
||||
function getPageScrollElement(): HTMLElement | null {
|
||||
return pageRef.value?.closest(".pages") || null;
|
||||
}
|
||||
|
||||
async function reloadCustomers(): Promise<void> {
|
||||
pageIndex.value = 0;
|
||||
totalCount.value = 0;
|
||||
customerList.value = [];
|
||||
noMore.value = false;
|
||||
await loadCustomers();
|
||||
}
|
||||
|
||||
async function loadCustomers(): Promise<void> {
|
||||
if (loading.value || noMore.value) {
|
||||
return;
|
||||
}
|
||||
const token = loadToken + 1;
|
||||
loadToken = token;
|
||||
loading.value = true;
|
||||
try {
|
||||
const result = await CustomerAPI.searchPage({
|
||||
index: pageIndex.value,
|
||||
size: pageSize,
|
||||
equalsExample: buildSearchReq()
|
||||
});
|
||||
if (token !== loadToken) {
|
||||
return;
|
||||
}
|
||||
customerList.value = customerList.value.concat(result.list || []);
|
||||
totalCount.value = result.total || 0;
|
||||
pageIndex.value += 1;
|
||||
noMore.value = customerList.value.length >= totalCount.value || (result.list || []).length < pageSize;
|
||||
} finally {
|
||||
if (token === loadToken) {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function exportCustomers(): Promise<void> {
|
||||
if (exporting.value) {
|
||||
return;
|
||||
}
|
||||
exporting.value = true;
|
||||
try {
|
||||
const blob = await CustomerAPI.exportExcel(buildSearchReq());
|
||||
Toolkit.downloadBlob(blob, `客户列表-${Time.formatDate(new Date())}.xlsx`);
|
||||
showToast("导出已开始");
|
||||
} finally {
|
||||
exporting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function goDetail(customer: Customer): Promise<void> {
|
||||
if (!customer.id) {
|
||||
showToast("客户 ID 缺失,无法打开详情", "warning");
|
||||
return;
|
||||
}
|
||||
await router.push(`/management/customers/${customer.id}`);
|
||||
}
|
||||
|
||||
function buildSearchReq(): CustomerSearchReq {
|
||||
return {
|
||||
keyword: keyword.value || undefined
|
||||
};
|
||||
}
|
||||
|
||||
function showToast(message: string, theme: "success" | "warning" = "success"): void {
|
||||
Toast({
|
||||
message,
|
||||
theme
|
||||
});
|
||||
}
|
||||
|
||||
watch(keyword, async () => {
|
||||
window.clearTimeout(keywordTimer);
|
||||
keywordTimer = window.setTimeout(async () => {
|
||||
await reloadCustomers();
|
||||
}, 300);
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
await reloadCustomers();
|
||||
Scroller.addBottomListener(scrollListenerName, loadCustomers, {
|
||||
canLoad: () => !loading.value && !noMore.value,
|
||||
getElement: getPageScrollElement
|
||||
});
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
window.clearTimeout(keywordTimer);
|
||||
Scroller.removeListener(scrollListenerName);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.customer-list {
|
||||
padding: calc(var(--app-nav-height)) 0 5.5rem 0;
|
||||
|
||||
.search-bar {
|
||||
top: var(--app-nav-height);
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 10;
|
||||
padding: .75rem 1rem;
|
||||
position: sticky;
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
.total {
|
||||
display: flex;
|
||||
margin-top: 1rem;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.items {
|
||||
gap: .5rem;
|
||||
padding: 1rem;
|
||||
display: grid;
|
||||
|
||||
.item {
|
||||
gap: .75rem;
|
||||
padding: .5rem;
|
||||
display: flex;
|
||||
text-align: left;
|
||||
background: #f7faff;
|
||||
align-items: center;
|
||||
border: 1px solid var(--td-border-level-1-color);
|
||||
border-radius: .875rem;
|
||||
justify-content: space-between;
|
||||
|
||||
.avatar {
|
||||
flex: none;
|
||||
width: 3.5rem;
|
||||
height: 3.5rem;
|
||||
border-radius: .5rem;
|
||||
|
||||
&.fallback {
|
||||
color: #2563eb;
|
||||
display: flex;
|
||||
font-size: 1.25rem;
|
||||
background: #dbeafe;
|
||||
align-items: center;
|
||||
font-weight: 600;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
.content {
|
||||
gap: .75rem;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
|
||||
.meta {
|
||||
font-size: .875rem;
|
||||
}
|
||||
|
||||
.line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.name {
|
||||
gap: .375rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.value {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.empty-block,
|
||||
.load-state {
|
||||
padding: 1rem;
|
||||
color: #52607a;
|
||||
text-align: center;
|
||||
background: #f8fbff;
|
||||
border-radius: 1rem;
|
||||
}
|
||||
|
||||
.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 {
|
||||
|
||||
.search-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,521 @@
|
||||
<template>
|
||||
<div v-can="gaoPermissionCodes.stat.read" class="data-charts-page">
|
||||
<section 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>
|
||||
|
||||
<registration-event-record-calendar
|
||||
v-model="monthCursor"
|
||||
v-model:selected-registration-event-id-list="selectedRegistrationEventIdList"
|
||||
:registration-event-list="registrationEventList"
|
||||
empty-text="当前月份暂无登记事件记录"
|
||||
: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>
|
||||
</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 CustomerAPI from "@/api/CustomerAPI";
|
||||
import RegistrationEventAPI from "@/api/RegistrationEventAPI";
|
||||
import RegistrationEventRecordAPI from "@/api/RegistrationEventRecordAPI";
|
||||
import RegistrationEventRecordCalendar from "@/components/RegistrationEventRecordCalendar.vue";
|
||||
import type { CustomerDailyStatView, CustomerGenderStatView } from "@/types/Customer";
|
||||
import type { RegistrationEvent } from "@/types/RegistrationEvent";
|
||||
import type { RegistrationEventRecord, RegistrationEventRecordCalendarView, RegistrationEventRecordDailyStatView } from "@/types/RegistrationEventRecord";
|
||||
import { Time } from "timi-web";
|
||||
import { gaoPermissionCodes } from "@/utils/gaoPermission";
|
||||
|
||||
interface SummaryItem {
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
interface ChartProfile {
|
||||
id: string;
|
||||
title: string;
|
||||
option: EChartsOption;
|
||||
}
|
||||
|
||||
defineOptions({
|
||||
name: "ManagementDataCharts"
|
||||
});
|
||||
|
||||
use([
|
||||
BarChart,
|
||||
LineChart,
|
||||
GridComponent,
|
||||
LegendComponent,
|
||||
TooltipComponent,
|
||||
CanvasRenderer
|
||||
]);
|
||||
|
||||
const chartRefs = new Map<string, HTMLElement>();
|
||||
const chartInstances = new Map<string, ECharts>();
|
||||
const registrationEventList = ref<RegistrationEvent[]>([]);
|
||||
const selectedRegistrationEventIdList = ref<string[]>([]);
|
||||
const customerDailyStatList = ref<CustomerDailyStatView[]>([]);
|
||||
const customerGenderStatList = ref<CustomerGenderStatView[]>([]);
|
||||
const recordDailyStatMap = ref<Record<string, RegistrationEventRecordDailyStatView[]>>({});
|
||||
const monthRecords = ref<RegistrationEventRecord[]>([]);
|
||||
const loading = ref(false);
|
||||
const calendarLoading = ref(false);
|
||||
const monthCursor = ref(getMonthStart(new Date()));
|
||||
const beginDate = getDateStart(addDays(new Date(), -6));
|
||||
const endDate = getDateEnd(new Date());
|
||||
const dateTextList = buildDateTextList(beginDate, endDate);
|
||||
const timestampRange = {
|
||||
beginRegisteredAt: Time.toTimestamp(beginDate),
|
||||
endRegisteredAt: Time.toTimestamp(endDate)
|
||||
};
|
||||
|
||||
const summaryList = computed<SummaryItem[]>(() => [
|
||||
{
|
||||
label: "当前客户",
|
||||
value: `${getLatestTotalCustomerCount()} 人`
|
||||
},
|
||||
{
|
||||
label: "本周新增",
|
||||
value: `${sumCustomerNewCount()} 人`
|
||||
},
|
||||
{
|
||||
label: "本周登记",
|
||||
value: `${sumActionDailyCount()} 次`
|
||||
}
|
||||
]);
|
||||
|
||||
const chartList = computed<ChartProfile[]>(() => [
|
||||
{
|
||||
id: "customer-growth",
|
||||
title: "客户增长趋势",
|
||||
option: buildCustomerGrowthOption()
|
||||
},
|
||||
{
|
||||
id: "record-trend",
|
||||
title: "登记量趋势",
|
||||
option: buildRecordTrendOption()
|
||||
},
|
||||
{
|
||||
id: "customer-value",
|
||||
title: "事件结构",
|
||||
option: buildEventStructureOption()
|
||||
},
|
||||
{
|
||||
id: "customer-gender",
|
||||
title: "性别统计",
|
||||
option: buildGenderStatOption()
|
||||
}
|
||||
]);
|
||||
|
||||
watch(chartList, async () => {
|
||||
await nextTick();
|
||||
renderCharts();
|
||||
});
|
||||
|
||||
watch(monthCursor, async () => {
|
||||
await loadMonthRecords();
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
await loadChartsData();
|
||||
await loadMonthRecords();
|
||||
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 loadChartsData(): Promise<void> {
|
||||
loading.value = true;
|
||||
try {
|
||||
registrationEventList.value = await RegistrationEventAPI.enabledList();
|
||||
const validRegistrationEventList = registrationEventList.value.filter((item): item is RegistrationEvent & { id: string } => Boolean(item.id));
|
||||
selectedRegistrationEventIdList.value = validRegistrationEventList.map(item => item.id);
|
||||
const [customerStatList, genderStatList, recordDailyStatList] = await Promise.all([
|
||||
CustomerAPI.dailyStat({
|
||||
beginCreatedAt: timestampRange.beginRegisteredAt,
|
||||
endCreatedAt: timestampRange.endRegisteredAt
|
||||
}),
|
||||
CustomerAPI.genderStat(),
|
||||
Promise.all(validRegistrationEventList.map(async item => ({
|
||||
registrationEventId: item.id,
|
||||
stats: await RegistrationEventRecordAPI.allCustomerRegistrationEventDailyStat({
|
||||
registrationEventId: item.id,
|
||||
beginRegisteredAt: timestampRange.beginRegisteredAt,
|
||||
endRegisteredAt: timestampRange.endRegisteredAt
|
||||
})
|
||||
})))
|
||||
]);
|
||||
customerDailyStatList.value = customerStatList;
|
||||
customerGenderStatList.value = genderStatList;
|
||||
recordDailyStatMap.value = Object.fromEntries(recordDailyStatList.map(item => [item.registrationEventId, item.stats]));
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMonthRecords(): Promise<void> {
|
||||
const begin = getCalendarBeginDate(monthCursor.value);
|
||||
const end = getDateEnd(addDays(begin, 41));
|
||||
calendarLoading.value = true;
|
||||
try {
|
||||
const calendarRecordList = await RegistrationEventRecordAPI.calendarRangeList({
|
||||
beginRegisteredAt: Time.toTimestamp(begin),
|
||||
endRegisteredAt: Time.toTimestamp(end)
|
||||
});
|
||||
monthRecords.value = calendarRecordList.map(buildCalendarRecord);
|
||||
} finally {
|
||||
calendarLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
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: dateTextList.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 buildCustomerGrowthOption(): EChartsOption {
|
||||
return {
|
||||
...buildBaseOption(),
|
||||
series: [
|
||||
{
|
||||
name: "客户总量",
|
||||
type: "line",
|
||||
smooth: true,
|
||||
data: dateTextList.map(dateText => getCustomerDailyStat(dateText)?.totalCustomerCount || 0)
|
||||
},
|
||||
{
|
||||
name: "新客户量",
|
||||
type: "bar",
|
||||
data: dateTextList.map(dateText => getCustomerDailyStat(dateText)?.newCustomerCount || 0)
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
function buildRecordTrendOption(): EChartsOption {
|
||||
return {
|
||||
...buildBaseOption(),
|
||||
series: registrationEventList.value.map(action => ({
|
||||
name: action.name || "未命名事件",
|
||||
type: "bar",
|
||||
stack: "record",
|
||||
itemStyle: getRegistrationEventColor(action) ? {
|
||||
color: getRegistrationEventColor(action)
|
||||
} : undefined,
|
||||
data: dateTextList.map(dateText => countRecordByRegistrationEventAndDate(action.id || "", dateText))
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
function buildEventStructureOption(): EChartsOption {
|
||||
return {
|
||||
...buildBaseOption(),
|
||||
xAxis: {
|
||||
type: "category",
|
||||
data: registrationEventList.value.map(item => item.name || "未命名事件"),
|
||||
axisTick: {
|
||||
show: false
|
||||
},
|
||||
axisLabel: {
|
||||
color: "#52607a"
|
||||
}
|
||||
},
|
||||
series: [{
|
||||
name: "登记次数",
|
||||
type: "bar",
|
||||
data: registrationEventList.value.map(item => ({
|
||||
value: countRecordByRegistrationEvent(item.id || ""),
|
||||
itemStyle: getRegistrationEventColor(item) ? {
|
||||
color: getRegistrationEventColor(item)
|
||||
} : undefined
|
||||
}))
|
||||
}]
|
||||
};
|
||||
}
|
||||
|
||||
function buildGenderStatOption(): EChartsOption {
|
||||
return {
|
||||
...buildBaseOption(),
|
||||
xAxis: {
|
||||
type: "category",
|
||||
data: ["男", "女", "未知"],
|
||||
axisTick: {
|
||||
show: false
|
||||
},
|
||||
axisLabel: {
|
||||
color: "#52607a"
|
||||
}
|
||||
},
|
||||
series: [{
|
||||
name: "客户数量",
|
||||
type: "bar",
|
||||
data: [
|
||||
getGenderCount("MALE"),
|
||||
getGenderCount("FEMALE"),
|
||||
getGenderCount("UNKNOWN")
|
||||
]
|
||||
}]
|
||||
};
|
||||
}
|
||||
|
||||
function getLatestTotalCustomerCount(): number {
|
||||
const latest = customerDailyStatList.value[customerDailyStatList.value.length - 1];
|
||||
return latest?.totalCustomerCount || 0;
|
||||
}
|
||||
|
||||
function sumCustomerNewCount(): number {
|
||||
return customerDailyStatList.value.reduce((sum, item) => sum + (item.newCustomerCount || 0), 0);
|
||||
}
|
||||
|
||||
function sumActionDailyCount(): number {
|
||||
return registrationEventList.value.reduce((sum, item) => sum + countRecordByRegistrationEvent(item.id || ""), 0);
|
||||
}
|
||||
|
||||
function getCustomerDailyStat(dateText: string): CustomerDailyStatView | undefined {
|
||||
const dateValue = Number(dateText.replaceAll("-", ""));
|
||||
return customerDailyStatList.value.find(item => item.dateValue === dateValue);
|
||||
}
|
||||
|
||||
function countRecordByRegistrationEventAndDate(registrationEventId: string, dateText: string): number {
|
||||
const dateValue = Number(dateText.replaceAll("-", ""));
|
||||
return recordDailyStatMap.value[registrationEventId]?.find(item => item.dateValue === dateValue)?.dailyCount || 0;
|
||||
}
|
||||
|
||||
function countRecordByRegistrationEvent(registrationEventId: string): number {
|
||||
return (recordDailyStatMap.value[registrationEventId] || []).reduce((sum, item) => sum + (item.dailyCount || 0), 0);
|
||||
}
|
||||
|
||||
function getRegistrationEventColor(registrationEvent: RegistrationEvent): string | undefined {
|
||||
return registrationEvent.color || undefined;
|
||||
}
|
||||
|
||||
function getGenderCount(genderCode: "MALE" | "FEMALE" | "UNKNOWN"): number {
|
||||
return customerGenderStatList.value.find(item => item.genderCode === genderCode)?.count || 0;
|
||||
}
|
||||
|
||||
function buildCalendarRecord(record: RegistrationEventRecordCalendarView): RegistrationEventRecord {
|
||||
return {
|
||||
id: record.id,
|
||||
customerId: record.customerId,
|
||||
registrationEventId: record.registrationEventId,
|
||||
registeredAt: record.registeredAt,
|
||||
customer: record.customerId || record.customerCode || record.customerName ? {
|
||||
id: record.customerId,
|
||||
code: record.customerCode,
|
||||
name: record.customerName
|
||||
} : undefined
|
||||
};
|
||||
}
|
||||
|
||||
function getDateStart(date: Date): Date {
|
||||
return new Date(date.getFullYear(), date.getMonth(), date.getDate(), 0, 0, 0, 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 getDateEnd(date: Date): Date {
|
||||
return new Date(date.getFullYear(), date.getMonth(), date.getDate(), 23, 59, 59, 999);
|
||||
}
|
||||
|
||||
function addDays(date: Date, days: number): Date {
|
||||
const value = new Date(date);
|
||||
value.setDate(value.getDate() + days);
|
||||
return value;
|
||||
}
|
||||
|
||||
function buildDateTextList(begin: Date, end: Date): string[] {
|
||||
const list: string[] = [];
|
||||
for (let item = getDateStart(begin); item <= end; item = addDays(item, 1)) {
|
||||
list.push(Time.formatDate(item));
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.data-charts-page {
|
||||
gap: 1rem;
|
||||
padding: calc(var(--app-nav-height) + 1rem) 1rem 1rem 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.summary {
|
||||
gap: .75rem;
|
||||
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: 1rem;
|
||||
flex-direction: column;
|
||||
|
||||
span {
|
||||
color: #52607a;
|
||||
font-size: .75rem;
|
||||
}
|
||||
|
||||
strong {
|
||||
margin-top: .375rem;
|
||||
color: #0f172a;
|
||||
font-size: 1.125rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.chart-section {
|
||||
padding: 1rem;
|
||||
background: rgba(255, 255, 255, .92);
|
||||
box-shadow: var(--td-shadow-1);
|
||||
border-radius: 1.25rem;
|
||||
|
||||
h2 {
|
||||
margin: 0 0 .875rem 0;
|
||||
color: #0f172a;
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.chart {
|
||||
width: 100%;
|
||||
height: 16rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
:global(html.theme-dark) {
|
||||
.data-charts-page {
|
||||
.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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.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,98 @@
|
||||
<template>
|
||||
<div class="password-page">
|
||||
<t-form class="form" label-align="left">
|
||||
<t-form-item label="旧密码">
|
||||
<t-input v-model.trim="formModel.oldPassword" type="password" placeholder="旧密码" borderless />
|
||||
</t-form-item>
|
||||
<t-form-item label="新密码">
|
||||
<t-input v-model.trim="formModel.newPassword" type="password" placeholder="新密码" borderless />
|
||||
</t-form-item>
|
||||
<t-form-item label="确认密码">
|
||||
<t-input v-model.trim="formModel.confirmPassword" type="password" placeholder="确认密码" borderless />
|
||||
</t-form-item>
|
||||
</t-form>
|
||||
<t-button class="save" theme="primary" :loading="isSaving" @click="savePassword">保存</t-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Toast } from "tdesign-mobile-vue";
|
||||
import { UserAPI, useUserStore } from "timi-web";
|
||||
|
||||
defineOptions({
|
||||
name: "ManagementPassword"
|
||||
});
|
||||
|
||||
const router = useRouter();
|
||||
const userStore = useUserStore();
|
||||
const isSaving = ref(false);
|
||||
const formModel = reactive({
|
||||
oldPassword: "",
|
||||
newPassword: "",
|
||||
confirmPassword: ""
|
||||
});
|
||||
|
||||
async function savePassword(): Promise<void> {
|
||||
if (!formModel.oldPassword || !formModel.newPassword) {
|
||||
Toast({
|
||||
theme: "warning",
|
||||
direction: "row",
|
||||
message: "请填写旧密码和新密码"
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (formModel.newPassword !== formModel.confirmPassword) {
|
||||
Toast({
|
||||
theme: "warning",
|
||||
direction: "row",
|
||||
message: "两次输入的新密码不一致"
|
||||
});
|
||||
return;
|
||||
}
|
||||
isSaving.value = true;
|
||||
try {
|
||||
await UserAPI.updatePassword({
|
||||
oldValue: formModel.oldPassword,
|
||||
newValue: formModel.newPassword
|
||||
});
|
||||
userStore.clearLoginUser();
|
||||
Toast({
|
||||
theme: "success",
|
||||
direction: "row",
|
||||
message: "密码已更新,请重新登录"
|
||||
});
|
||||
await router.replace("/login");
|
||||
} finally {
|
||||
isSaving.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.password-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;
|
||||
}
|
||||
|
||||
.save {
|
||||
left: 1rem;
|
||||
right: 1rem;
|
||||
bottom: calc(.75rem + env(safe-area-inset-bottom, 0));
|
||||
position: fixed;
|
||||
}
|
||||
}
|
||||
|
||||
:global(html.theme-dark) {
|
||||
.password-page {
|
||||
.form {
|
||||
background: rgba(15, 23, 42, .88);
|
||||
box-shadow: 0 .75rem 2rem rgba(2, 6, 23, .32);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,279 @@
|
||||
<template>
|
||||
<div class="profile-page">
|
||||
<t-form class="form" label-align="left">
|
||||
<t-form-item label="头像">
|
||||
<photo-field
|
||||
v-model="formModel.avatar"
|
||||
add-text="更换"
|
||||
alt-text="用户头像"
|
||||
size="compact"
|
||||
:preview="false"
|
||||
:show-remove="false"
|
||||
@add="changeAvatar"
|
||||
/>
|
||||
</t-form-item>
|
||||
<t-form-item label="用户名">
|
||||
<t-input v-model.trim="formModel.name" placeholder="用户名" borderless />
|
||||
</t-form-item>
|
||||
<t-form-item label="昵称">
|
||||
<t-input v-model.trim="formModel.nickname" placeholder="昵称" borderless />
|
||||
</t-form-item>
|
||||
<t-form-item label="性别">
|
||||
<t-radio-group class="sex" v-model="formModel.gender" variant="default-filled">
|
||||
<t-radio :block="false" borderless value="男">男</t-radio>
|
||||
<t-radio :block="false" borderless value="女">女</t-radio>
|
||||
<t-radio :block="false" borderless value="未知">未知</t-radio>
|
||||
</t-radio-group>
|
||||
</t-form-item>
|
||||
<t-form-item label="手机号">
|
||||
<t-input :model-value="formModel.mobile" readonly borderless />
|
||||
</t-form-item>
|
||||
</t-form>
|
||||
<input
|
||||
ref="avatarInputRef"
|
||||
class="avatar-input"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
@change="handleAvatarSelected"
|
||||
>
|
||||
<t-cell-group theme="card">
|
||||
<t-cell title="修改密码" arrow hover @click="goPassword" />
|
||||
</t-cell-group>
|
||||
<t-button class="save" theme="primary" :loading="isSaving" @click="saveProfile">保存</t-button>
|
||||
<t-button class="logout bg-white" theme="danger" variant="outline" @click="confirmLogout">退出登录</t-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { DialogPlugin, Toast } from "tdesign-mobile-vue";
|
||||
import { AttachmentAPI, UserAPI, useUserStore } from "timi-web";
|
||||
import PhotoField from "@/components/PhotoField.vue";
|
||||
import { compressUploadImage } from "@/utils/imageCompression";
|
||||
|
||||
type UserAvatarSource = NonNullable<Parameters<typeof UserAPI.getAvatarURL>[0]> & {
|
||||
name?: string;
|
||||
nick?: string;
|
||||
};
|
||||
|
||||
defineOptions({
|
||||
name: "ManagementProfile"
|
||||
});
|
||||
|
||||
const router = useRouter();
|
||||
const userStore = useUserStore();
|
||||
const avatarInputRef = ref<HTMLInputElement>();
|
||||
const avatarTempFileId = ref("");
|
||||
const isSaving = ref(false);
|
||||
|
||||
const formModel = reactive({
|
||||
id: "",
|
||||
name: "",
|
||||
avatar: "",
|
||||
nickname: "",
|
||||
role: "",
|
||||
gender: "未知",
|
||||
mobile: "",
|
||||
password: "",
|
||||
office: "",
|
||||
scope: "",
|
||||
status: "",
|
||||
lastLoginAt: ""
|
||||
});
|
||||
|
||||
function fillForm(): void {
|
||||
const user = userStore.loginUser.user;
|
||||
Object.assign(formModel, {
|
||||
id: user?.id || "",
|
||||
name: user?.name || "",
|
||||
avatar: buildUserAvatar(user),
|
||||
nickname: user?.nick || "",
|
||||
role: formatRoleCode(userStore.getRoleList()[0]),
|
||||
gender: sexToText(user?.sex),
|
||||
mobile: user?.phoneNo || "",
|
||||
password: "",
|
||||
office: "",
|
||||
scope: "",
|
||||
status: "",
|
||||
lastLoginAt: user?.lastLoginAt ? new Date(user.lastLoginAt).toLocaleString() : ""
|
||||
});
|
||||
}
|
||||
|
||||
function changeAvatar(): void {
|
||||
avatarInputRef.value?.click();
|
||||
}
|
||||
|
||||
async function handleAvatarSelected(event: Event): Promise<void> {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
const compressedFile = await compressUploadImage(file);
|
||||
const [tempFile] = await AttachmentAPI.uploadTemp(compressedFile);
|
||||
if (!tempFile?.id) {
|
||||
Toast({
|
||||
theme: "error",
|
||||
direction: "row",
|
||||
message: "头像上传失败"
|
||||
});
|
||||
return;
|
||||
}
|
||||
avatarTempFileId.value = tempFile.id;
|
||||
formModel.avatar = AttachmentAPI.getTempReadURL(tempFile.id);
|
||||
input.value = "";
|
||||
}
|
||||
|
||||
async function goPassword(): Promise<void> {
|
||||
await router.push("/management/profile/password");
|
||||
}
|
||||
|
||||
async function saveProfile(): Promise<void> {
|
||||
if (!formModel.name) {
|
||||
Toast({
|
||||
theme: "warning",
|
||||
direction: "row",
|
||||
message: "请填写用户名"
|
||||
});
|
||||
return;
|
||||
}
|
||||
isSaving.value = true;
|
||||
try {
|
||||
const result = await UserAPI.updateCurrent({
|
||||
id: formModel.id,
|
||||
name: formModel.name,
|
||||
nick: formModel.nickname,
|
||||
sex: textToSex(formModel.gender),
|
||||
tempFileIdList: avatarTempFileId.value ? [avatarTempFileId.value] : undefined
|
||||
});
|
||||
await userStore.updateLoginUser(result);
|
||||
Toast({
|
||||
theme: "success",
|
||||
direction: "row",
|
||||
message: "资料已保存"
|
||||
});
|
||||
await router.back();
|
||||
} finally {
|
||||
isSaving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmLogout(): Promise<void> {
|
||||
const dialog = DialogPlugin.confirm({
|
||||
title: "退出登录",
|
||||
content: "确定要退出当前账号吗?",
|
||||
cancelBtn: "取消",
|
||||
confirmBtn: "退出",
|
||||
onCancel() {
|
||||
dialog.destroy();
|
||||
},
|
||||
async onConfirm() {
|
||||
dialog.destroy();
|
||||
await logout();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function logout(): Promise<void> {
|
||||
await userStore.logout();
|
||||
await router.replace("/login");
|
||||
}
|
||||
|
||||
function sexToText(sex?: number): "男" | "女" | "未知" {
|
||||
if (sex === 1) {
|
||||
return "男";
|
||||
}
|
||||
if (sex === 2) {
|
||||
return "女";
|
||||
}
|
||||
return "未知";
|
||||
}
|
||||
|
||||
function formatRoleCode(roleCode?: string): string {
|
||||
if (!roleCode) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const codePartList = roleCode.split(":");
|
||||
return codePartList[codePartList.length - 1] || roleCode;
|
||||
}
|
||||
|
||||
function textToSex(text: string): number {
|
||||
if (text === "男") {
|
||||
return 1;
|
||||
}
|
||||
if (text === "女") {
|
||||
return 2;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function buildFallbackAvatar(name: string): string {
|
||||
const text = encodeURIComponent((name.trim() || "用户").slice(0, 2));
|
||||
return [
|
||||
"data:image/svg+xml;charset=UTF-8,",
|
||||
"%3Csvg xmlns='http://www.w3.org/2000/svg' width='120' height='120' viewBox='0 0 120 120'%3E",
|
||||
"%3Crect width='120' height='120' rx='24' fill='%230f766e'/%3E",
|
||||
"%3Ctext x='50%25' y='54%25' fill='white' font-size='36' font-family='Arial,sans-serif' text-anchor='middle' dominant-baseline='middle'%3E",
|
||||
text,
|
||||
"%3C/text%3E%3C/svg%3E"
|
||||
].join("");
|
||||
}
|
||||
|
||||
function buildUserAvatar(user?: UserAvatarSource): string {
|
||||
const name = user?.nick || user?.name || "用户";
|
||||
return UserAPI.getAvatarURL(user) || buildFallbackAvatar(name);
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fillForm();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.profile-page {
|
||||
gap: 1rem;
|
||||
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;
|
||||
|
||||
.sex {
|
||||
gap: .5rem;
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
|
||||
.avatar-input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.save,
|
||||
.logout {
|
||||
left: 1rem;
|
||||
right: 1rem;
|
||||
position: fixed;
|
||||
}
|
||||
|
||||
.save {
|
||||
bottom: calc(3.75rem + env(safe-area-inset-bottom, 0));
|
||||
}
|
||||
|
||||
.logout {
|
||||
bottom: calc(.75rem + env(safe-area-inset-bottom, 0));
|
||||
}
|
||||
}
|
||||
|
||||
:global(html.theme-dark) {
|
||||
.profile-page {
|
||||
.form {
|
||||
background: rgba(15, 23, 42, .88);
|
||||
box-shadow: 0 .75rem 2rem rgba(2, 6, 23, .32);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,360 @@
|
||||
<template>
|
||||
<div v-can="gaoPermissionCodes.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 {
|
||||
calculateManagementQrCodeGrid,
|
||||
downloadManagementQrCodeImages,
|
||||
printManagementQrCodeImages,
|
||||
splitManagementQrCodePages
|
||||
} from "@/utils/managementQrCode";
|
||||
import { gaoPermissionCodes } from "@/utils/gaoPermission";
|
||||
|
||||
defineOptions({
|
||||
name: "ManagementQrCode"
|
||||
});
|
||||
|
||||
interface ManagementQrCodeFormModel {
|
||||
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<ManagementQrCodeFormModel>({
|
||||
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(() => splitManagementQrCodePages(codeList.value, maxCodeLength.value));
|
||||
const visiblePreviewPages = computed(() => previewPages.value.slice(0, 1));
|
||||
const paperGrid = computed(() => calculateManagementQrCodeGrid(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<ManagementQrCodeFormModel>;
|
||||
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 downloadManagementQrCodeImages({
|
||||
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 printManagementQrCodeImages({
|
||||
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>
|
||||
@@ -0,0 +1,146 @@
|
||||
<template>
|
||||
<div class="settings-page">
|
||||
<t-cell-group title="显示" theme="card">
|
||||
<t-cell class="theme" title="显示模式">
|
||||
<template #right-icon>
|
||||
<segmented-slider
|
||||
class="theme-slider"
|
||||
:model-value="globalUIStore.themeMode"
|
||||
:options="themeOptions"
|
||||
@change="handleThemeChange"
|
||||
/>
|
||||
</template>
|
||||
</t-cell>
|
||||
</t-cell-group>
|
||||
<t-cell-group title="声音" theme="card">
|
||||
<t-cell title="扫码成功">
|
||||
<template #right-icon>
|
||||
<t-switch :model-value="settingStore.sound.scanSuccess" @change="value => handleScanSuccessChange(value === true)" />
|
||||
</template>
|
||||
</t-cell>
|
||||
<t-cell title="登记成功">
|
||||
<template #right-icon>
|
||||
<t-switch :model-value="settingStore.sound.registerSuccess" @change="value => handleRegisterSuccessChange(value === true)" />
|
||||
</template>
|
||||
</t-cell>
|
||||
<t-cell title="音量">
|
||||
<template #right-icon>
|
||||
<div class="volume-field">
|
||||
<t-slider
|
||||
:model-value="settingStore.sound.volume"
|
||||
:min="0"
|
||||
:max="100"
|
||||
:step="1"
|
||||
@change="handleVolumeChange"
|
||||
/>
|
||||
<span v-text="`${settingStore.sound.volume}%`" />
|
||||
</div>
|
||||
</template>
|
||||
</t-cell>
|
||||
</t-cell-group>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { ThemeMode } from "@/store/globalUI";
|
||||
import { useGlobalUIStore } from "@/store/globalUI";
|
||||
import SegmentedSlider from "@/components/SegmentedSlider.vue";
|
||||
import { useSettingStore } from "@/store/settingStore";
|
||||
|
||||
defineOptions({
|
||||
name: "ManagementSettings"
|
||||
});
|
||||
|
||||
const globalUIStore = useGlobalUIStore();
|
||||
const settingStore = useSettingStore();
|
||||
const themeOptions: Array<{ label: string; value: ThemeMode }> = [
|
||||
{ label: "浅色", value: "light" },
|
||||
{ label: "深色", value: "dark" },
|
||||
{ label: "跟随系统", value: "system" }
|
||||
];
|
||||
|
||||
function handleThemeChange(value: string): void {
|
||||
globalUIStore.setThemeMode(value as ThemeMode);
|
||||
}
|
||||
|
||||
async function handleScanSuccessChange(value: boolean): Promise<void> {
|
||||
settingStore.setSound({
|
||||
scanSuccess: value
|
||||
});
|
||||
if (value) {
|
||||
await playSettingSoundPreview("scanSuccess");
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRegisterSuccessChange(value: boolean): Promise<void> {
|
||||
settingStore.setSound({
|
||||
registerSuccess: value
|
||||
});
|
||||
if (value) {
|
||||
await playSettingSoundPreview("registerSuccess");
|
||||
}
|
||||
}
|
||||
|
||||
function handleVolumeChange(value: number | number[]): void {
|
||||
settingStore.setSound({
|
||||
volume: Array.isArray(value) ? value[0] : value
|
||||
});
|
||||
}
|
||||
|
||||
async function playSettingSoundPreview(type: "scanSuccess" | "registerSuccess"): Promise<void> {
|
||||
await settingStore.unlockSoundPlayers();
|
||||
if (type === "scanSuccess") {
|
||||
await settingStore.playScanSuccessSound();
|
||||
return;
|
||||
}
|
||||
|
||||
await settingStore.playRegisterSuccessSound();
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.settings-page {
|
||||
padding: calc(var(--app-nav-height) + 1rem) 0 1rem 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.theme {
|
||||
|
||||
:deep(.t-cell__title) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.theme-slider {
|
||||
width: 12rem;
|
||||
flex: 0 0 12rem;
|
||||
}
|
||||
}
|
||||
|
||||
.volume-field {
|
||||
gap: .5rem;
|
||||
width: 16rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
:deep(.t-slider) {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
span {
|
||||
color: #52607a;
|
||||
font-size: .8125rem;
|
||||
text-align: right;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
:global(html.theme-dark) {
|
||||
.settings-page {
|
||||
.volume-field span {
|
||||
color: #94a3b8;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,100 @@
|
||||
<template>
|
||||
<div v-role="permissionManagementRoleAccess" v-can="pageAccess" class="permission-edit">
|
||||
<t-form class="form" label-align="left">
|
||||
<t-form-item label="名称">
|
||||
<t-input v-model.trim="formModel.name" placeholder="权限名称" borderless />
|
||||
</t-form-item>
|
||||
<t-form-item label="代码">
|
||||
<t-input v-model.trim="formModel.code" placeholder="如 customer:read" borderless />
|
||||
</t-form-item>
|
||||
<t-form-item label="模块">
|
||||
<t-input v-model.trim="formModel.moduleCode" placeholder="模块代码" borderless />
|
||||
</t-form-item>
|
||||
<t-form-item label="说明">
|
||||
<t-textarea v-model.trim="formModel.description" class="textarea" placeholder="权限说明" autosize borderless />
|
||||
</t-form-item>
|
||||
</t-form>
|
||||
<t-button v-can="savePermissionCode" class="save" theme="primary" @click="savePermission">保存</t-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Toast } from "tdesign-mobile-vue";
|
||||
import { gaoModuleCode, usePermissionSettingsStore } from "@/store/permissionSettingsStore";
|
||||
import { corePermissionCodes, permissionManagementRoleAccess } from "@/utils/gaoPermission";
|
||||
|
||||
defineOptions({
|
||||
name: "PermissionEdit"
|
||||
});
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const permissionStore = usePermissionSettingsStore();
|
||||
const routeId = computed(() => String(route.params.id || ""));
|
||||
const isCreateMode = computed(() => !routeId.value);
|
||||
const savePermissionCode = computed(() => isCreateMode.value ? corePermissionCodes.permission.create : corePermissionCodes.permission.update);
|
||||
const pageAccess = computed(() => ({
|
||||
value: [corePermissionCodes.role.read, corePermissionCodes.permission.read, savePermissionCode.value],
|
||||
mode: "and" as const
|
||||
}));
|
||||
const formModel = reactive({
|
||||
id: "",
|
||||
moduleCode: gaoModuleCode,
|
||||
code: "",
|
||||
name: "",
|
||||
description: ""
|
||||
});
|
||||
|
||||
async function fillForm(): Promise<void> {
|
||||
await permissionStore.loadAll();
|
||||
const current = await permissionStore.loadPermissionDetail(routeId.value);
|
||||
Object.assign(formModel, {
|
||||
id: current.id,
|
||||
moduleCode: current.moduleCode || gaoModuleCode,
|
||||
code: current.code,
|
||||
name: current.name,
|
||||
description: current.description
|
||||
});
|
||||
}
|
||||
|
||||
async function savePermission(): Promise<void> {
|
||||
const permission = await permissionStore.upsertPermission({
|
||||
id: formModel.id,
|
||||
moduleCode: gaoModuleCode,
|
||||
code: formModel.code,
|
||||
name: formModel.name,
|
||||
description: formModel.description
|
||||
}, isCreateMode.value);
|
||||
Toast({
|
||||
theme: "success",
|
||||
direction: "row",
|
||||
message: "权限已保存"
|
||||
});
|
||||
await router.push(isCreateMode.value ? "/management/permissions/items" : `/management/permissions/items/${permission?.id || formModel.id}`);
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await fillForm();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.permission-edit {
|
||||
padding: calc(var(--app-nav-height) + 1rem) 0 5rem 0;
|
||||
|
||||
.form {
|
||||
border-radius: .75rem;
|
||||
}
|
||||
|
||||
.textarea {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.save {
|
||||
left: 1rem;
|
||||
right: 1rem;
|
||||
bottom: calc(.75rem + env(safe-area-inset-bottom, 0));
|
||||
position: fixed;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,115 @@
|
||||
<template>
|
||||
<div v-role="permissionManagementRoleAccess" v-can="{ value: [corePermissionCodes.role.read, corePermissionCodes.permission.read], mode: 'and' }" class="permission-list">
|
||||
<t-cell-group v-if="sortedPermissions.length" theme="card">
|
||||
<t-swipe-cell
|
||||
v-for="item in sortedPermissions"
|
||||
:key="item.id"
|
||||
class="item"
|
||||
>
|
||||
<t-cell
|
||||
:title="item.name"
|
||||
>
|
||||
<template #note>
|
||||
<span class="code" v-text="item.code"></span>
|
||||
</template>
|
||||
</t-cell>
|
||||
<template #right>
|
||||
<t-button v-can="corePermissionCodes.permission.delete" class="delete" theme="danger" @click="removePermission(item.id)">删除</t-button>
|
||||
</template>
|
||||
</t-swipe-cell>
|
||||
</t-cell-group>
|
||||
<t-empty v-else-if="!permissionStore.loading" description="暂无权限" />
|
||||
<t-button v-can="corePermissionCodes.permission.create" class="add" theme="primary" type="button" @click="goCreate">
|
||||
<template #icon>
|
||||
<t-icon name="add" />
|
||||
</template>
|
||||
新增权限
|
||||
</t-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Permission } from "@/store/permissionSettingsStore";
|
||||
import { usePermissionSettingsStore } from "@/store/permissionSettingsStore";
|
||||
import { corePermissionCodes, permissionManagementRoleAccess } from "@/utils/gaoPermission";
|
||||
|
||||
defineOptions({
|
||||
name: "PermissionList"
|
||||
});
|
||||
|
||||
const router = useRouter();
|
||||
const permissionStore = usePermissionSettingsStore();
|
||||
const sortedPermissions = computed<Permission[]>(() => [...permissionStore.permissionList].sort((left, right) => left.code.localeCompare(right.code)));
|
||||
|
||||
async function goCreate(): Promise<void> {
|
||||
await router.push("/management/permissions/items/create");
|
||||
}
|
||||
|
||||
async function removePermission(id: string): Promise<void> {
|
||||
await permissionStore.removePermission(id);
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await permissionStore.loadAll();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.permission-list {
|
||||
padding: calc(var(--app-nav-height) + 1rem) 0 6rem 0;
|
||||
|
||||
.item {
|
||||
:deep(.t-cell__title) {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
:deep(.t-cell__title-text) {
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
:deep(.t-cell__note) {
|
||||
flex: 0 1 auto;
|
||||
min-width: 0;
|
||||
max-width: 56%;
|
||||
text-align: right;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
:deep(.t-cell__right) {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.code {
|
||||
width: 100%;
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
|
||||
:deep(.t-cell:after) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.delete {
|
||||
height: 100%;
|
||||
padding: 0 1rem;
|
||||
border-radius: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.add {
|
||||
left: 1rem;
|
||||
right: 1rem;
|
||||
bottom: calc(.75rem + env(safe-area-inset-bottom, 0));
|
||||
position: fixed;
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
||||
@@ -0,0 +1,114 @@
|
||||
<template>
|
||||
<div
|
||||
v-can="{ value: [corePermissionCodes.role.read, corePermissionCodes.permission.read], mode: 'and' }"
|
||||
class="permission-settings"
|
||||
>
|
||||
<t-cell-group title="权限配置" theme="card">
|
||||
<t-cell v-can="corePermissionCodes.role.read" title="角色管理" :note="`${permissionStore.roleList.length} 个角色`" arrow hover @click="go('/management/permissions/roles')">
|
||||
<template #leftIcon>
|
||||
<t-icon name="user-setting" size="1.75rem" />
|
||||
</template>
|
||||
</t-cell>
|
||||
<t-cell v-can="corePermissionCodes.permission.read" title="权限管理" :note="`${permissionStore.permissionList.length} 个权限`" arrow hover @click="go('/management/permissions/items')">
|
||||
<template #leftIcon>
|
||||
<t-icon name="secured" size="1.75rem" />
|
||||
</template>
|
||||
</t-cell>
|
||||
<t-cell v-can="rolePermissionAssignAccess" title="角色权限授权" note="为角色分配可用权限" arrow hover @click="go('/management/permissions/assign')">
|
||||
<template #leftIcon>
|
||||
<t-icon name="user-checked" size="1.75rem" />
|
||||
</template>
|
||||
</t-cell>
|
||||
<t-cell v-can="corePermissionCodes.userRole.read" title="用户角色授权" note="为用户分配业务角色" arrow hover @click="go('/management/permissions/user-roles')">
|
||||
<template #leftIcon>
|
||||
<t-icon name="usergroup" size="1.75rem" />
|
||||
</template>
|
||||
</t-cell>
|
||||
</t-cell-group>
|
||||
<div class="struct">
|
||||
<div class="header">
|
||||
<span class="title">当前角色结构</span>
|
||||
<span class="tips orange">上级角色包含子级角色所有权限</span>
|
||||
</div>
|
||||
<t-cell-group theme="card">
|
||||
<t-cell
|
||||
v-can="{ value: [corePermissionCodes.role.read, corePermissionCodes.permission.read], mode: 'and' }"
|
||||
v-for="item in permissionStore.roleTree"
|
||||
:key="item.id"
|
||||
:note="`${item.permissionIdList.length} 项权限`"
|
||||
arrow
|
||||
hover
|
||||
@click="goAssign(item.id)"
|
||||
>
|
||||
<template #title>
|
||||
<div class="title" v-text="item.name" :style="{ paddingLeft: `${item.level * 1.25}rem` }" />
|
||||
</template>
|
||||
</t-cell>
|
||||
</t-cell-group>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { usePermissionSettingsStore } from "@/store/permissionSettingsStore";
|
||||
import { corePermissionCodes } from "@/utils/gaoPermission";
|
||||
|
||||
defineOptions({
|
||||
name: "PermissionSettings"
|
||||
});
|
||||
|
||||
const router = useRouter();
|
||||
const permissionStore = usePermissionSettingsStore();
|
||||
const rolePermissionAssignAccess = {
|
||||
value: [
|
||||
corePermissionCodes.role.read,
|
||||
corePermissionCodes.permission.read,
|
||||
corePermissionCodes.rolePermission.read,
|
||||
corePermissionCodes.roleRelation.read
|
||||
],
|
||||
mode: "and"
|
||||
} as const;
|
||||
|
||||
async function go(path: string): Promise<void> {
|
||||
await router.push(path);
|
||||
}
|
||||
|
||||
async function goAssign(roleId: string): Promise<void> {
|
||||
await router.push({
|
||||
path: "/management/permissions/assign",
|
||||
query: {
|
||||
roleId
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await permissionStore.loadAll();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.permission-settings {
|
||||
gap: 1rem;
|
||||
padding: calc(var(--app-nav-height) + 1rem) 0 1rem 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.struct {
|
||||
|
||||
.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)));
|
||||
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, 0.4))));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,314 @@
|
||||
<template>
|
||||
<div v-can="pageAccess" class="registration-event-edit">
|
||||
<t-form class="form" label-align="left">
|
||||
<t-form-item label="名称">
|
||||
<t-input v-model.trim="formModel.name" placeholder="登记事件名称" borderless />
|
||||
</t-form-item>
|
||||
<t-form-item label="说明">
|
||||
<t-textarea v-model.trim="formModel.description" class="textarea" placeholder="事件说明" autosize borderless />
|
||||
</t-form-item>
|
||||
<t-form-item label="颜色">
|
||||
<label class="color-picker">
|
||||
<input v-model="formModel.color" type="color">
|
||||
<span v-text="formModel.color" />
|
||||
</label>
|
||||
</t-form-item>
|
||||
<t-form-item label="状态">
|
||||
<t-radio-group v-model="formModel.status" class="inline-options" variant="default-filled">
|
||||
<t-radio :block="false" borderless value="ACTIVE">有效</t-radio>
|
||||
<t-radio :block="false" borderless value="INACTIVE">无效</t-radio>
|
||||
</t-radio-group>
|
||||
</t-form-item>
|
||||
<t-form-item label="启用">
|
||||
<t-switch v-model="formModel.enabled" />
|
||||
</t-form-item>
|
||||
<t-form-item label="拍照">
|
||||
<t-switch v-model="formModel.requirePhoto" />
|
||||
</t-form-item>
|
||||
<t-form-item label="排序">
|
||||
<t-input v-model.trim="formModel.sortText" type="number" placeholder="排序值" borderless />
|
||||
</t-form-item>
|
||||
<t-form-item label="约束">
|
||||
<t-radio-group v-model="formModel.limitType" class="limit-options" variant="default-filled">
|
||||
<t-radio
|
||||
v-for="option in limitOptions"
|
||||
:key="option.value"
|
||||
:block="false"
|
||||
borderless
|
||||
:value="option.value"
|
||||
>
|
||||
<span v-text="option.label" />
|
||||
</t-radio>
|
||||
</t-radio-group>
|
||||
</t-form-item>
|
||||
<t-form-item v-if="isValueLimitType(formModel.limitType)" label="数值">
|
||||
<t-input v-model.trim="formModel.limitValueText" type="number" :placeholder="limitValuePlaceholder" borderless />
|
||||
</t-form-item>
|
||||
</t-form>
|
||||
<section class="roles">
|
||||
<div class="title">
|
||||
<span>可登记角色</span>
|
||||
<span class="hint">未选择角色时不限制角色</span>
|
||||
</div>
|
||||
<t-checkbox-group v-model="formModel.roleCodeList" class="group">
|
||||
<t-checkbox
|
||||
v-for="item in roleOptions"
|
||||
:key="item.code"
|
||||
:value="item.code"
|
||||
class="item"
|
||||
borderless
|
||||
>
|
||||
<span v-text="item.name || item.code" />
|
||||
</t-checkbox>
|
||||
</t-checkbox-group>
|
||||
</section>
|
||||
<t-button v-can="savePermissionCode" class="save" theme="primary" @click="saveRegistrationEvent">保存</t-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Toast } from "tdesign-mobile-vue";
|
||||
import type { Role } from "timi-web";
|
||||
import RegistrationEventAPI from "@/api/RegistrationEventAPI";
|
||||
import GaoUserAPI from "@/api/GaoUserAPI";
|
||||
import type { RegistrationEvent, RegistrationEventStatus, RegistrationLimitType } from "@/types/RegistrationEvent";
|
||||
import { gaoPermissionCodes } from "@/utils/gaoPermission";
|
||||
|
||||
type LimitOption = {
|
||||
label: string;
|
||||
value: RegistrationLimitType;
|
||||
};
|
||||
|
||||
defineOptions({
|
||||
name: "RegistrationEventEdit"
|
||||
});
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const routeId = computed(() => String(route.params.id || ""));
|
||||
const isCreateMode = computed(() => !routeId.value);
|
||||
const savePermissionCode = computed(() => isCreateMode.value ? gaoPermissionCodes.registrationEvent.create : gaoPermissionCodes.registrationEvent.update);
|
||||
const pageAccess = computed(() => ({
|
||||
value: [gaoPermissionCodes.registrationEvent.read, gaoPermissionCodes.user.read, savePermissionCode.value],
|
||||
mode: "and" as const
|
||||
}));
|
||||
const roleOptions = ref<Role[]>([]);
|
||||
const limitOptions: LimitOption[] = [
|
||||
{ label: "不限", value: "NONE" },
|
||||
{ label: "上午/下午各一次", value: "DAILY_HALF_DAY" },
|
||||
{ label: "每天一次", value: "NATURAL_DAY" },
|
||||
{ label: "间隔小时", value: "INTERVAL_HOUR" },
|
||||
{ label: "每月一次", value: "NATURAL_MONTH" },
|
||||
{ label: "累计次数", value: "TOTAL_LIMIT" }
|
||||
];
|
||||
const formModel = reactive({
|
||||
id: "",
|
||||
name: "",
|
||||
description: "",
|
||||
color: "#2f88ff",
|
||||
status: "ACTIVE" as RegistrationEventStatus,
|
||||
enabled: true,
|
||||
requirePhoto: false,
|
||||
limitType: "NONE" as RegistrationLimitType,
|
||||
limitValueText: "",
|
||||
sortText: "0",
|
||||
roleCodeList: [] as string[]
|
||||
});
|
||||
const limitValuePlaceholder = computed(() => formModel.limitType === "INTERVAL_HOUR" ? "请输入间隔小时数" : "请输入累计上限次数");
|
||||
|
||||
function isValueLimitType(value: RegistrationLimitType): boolean {
|
||||
return value === "INTERVAL_HOUR" || value === "TOTAL_LIMIT";
|
||||
}
|
||||
|
||||
function resolveLimitValue(): number | undefined {
|
||||
if (!isValueLimitType(formModel.limitType)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const value = Number(formModel.limitValueText);
|
||||
return 0 < value ? Math.floor(value) : undefined;
|
||||
}
|
||||
|
||||
function validateForm(): boolean {
|
||||
if (!formModel.name.trim()) {
|
||||
showToast("请输入登记事件名称", "warning");
|
||||
return false;
|
||||
}
|
||||
if (isValueLimitType(formModel.limitType) && !resolveLimitValue()) {
|
||||
showToast("请输入有效的约束数值", "warning");
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function buildPayload(): RegistrationEvent {
|
||||
return {
|
||||
id: formModel.id || undefined,
|
||||
name: formModel.name.trim(),
|
||||
description: formModel.description,
|
||||
color: formModel.color,
|
||||
status: formModel.status,
|
||||
enabled: formModel.enabled,
|
||||
requirePhoto: formModel.requirePhoto,
|
||||
limitType: formModel.limitType,
|
||||
limitValue: resolveLimitValue(),
|
||||
sort: Math.floor(Number(formModel.sortText) || 0),
|
||||
roleCodeList: [...formModel.roleCodeList]
|
||||
};
|
||||
}
|
||||
|
||||
async function saveRegistrationEvent(): Promise<void> {
|
||||
if (!validateForm()) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = buildPayload();
|
||||
if (isCreateMode.value) {
|
||||
await RegistrationEventAPI.create(payload);
|
||||
} else {
|
||||
await RegistrationEventAPI.update(payload);
|
||||
}
|
||||
showToast("登记事件已保存");
|
||||
await router.replace("/management/registration-events");
|
||||
} catch {
|
||||
showToast("登记事件保存失败", "error");
|
||||
}
|
||||
}
|
||||
|
||||
function showToast(message: string, theme: "success" | "error" | "warning" = "success"): void {
|
||||
Toast({
|
||||
theme,
|
||||
direction: "row",
|
||||
message
|
||||
});
|
||||
}
|
||||
|
||||
async function fillForm(): Promise<void> {
|
||||
roleOptions.value = await GaoUserAPI.roleList();
|
||||
if (isCreateMode.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const current = await RegistrationEventAPI.detail(routeId.value);
|
||||
Object.assign(formModel, {
|
||||
id: current.id || "",
|
||||
name: current.name || "",
|
||||
description: current.description || "",
|
||||
color: current.color || "#2f88ff",
|
||||
status: current.status === "INACTIVE" ? "INACTIVE" : "ACTIVE",
|
||||
enabled: current.enabled ?? true,
|
||||
requirePhoto: current.requirePhoto ?? false,
|
||||
limitType: current.limitType || "NONE",
|
||||
limitValueText: current.limitValue ? String(current.limitValue) : "",
|
||||
sortText: String(current.sort ?? 0),
|
||||
roleCodeList: [...(current.roleCodeList || [])]
|
||||
});
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
await fillForm();
|
||||
} catch {
|
||||
showToast("登记事件加载失败", "error");
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.registration-event-edit {
|
||||
padding: calc(var(--app-nav-height) + 1rem) 0 5rem 0;
|
||||
|
||||
.form {
|
||||
border-radius: .75rem;
|
||||
}
|
||||
|
||||
.textarea {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.color-picker {
|
||||
gap: .75rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
input {
|
||||
padding: 0;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
span {
|
||||
color: #52607a;
|
||||
font-size: .875rem;
|
||||
}
|
||||
}
|
||||
|
||||
.inline-options,
|
||||
.limit-options {
|
||||
gap: .5rem;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.roles {
|
||||
gap: .75rem;
|
||||
display: flex;
|
||||
padding: 1rem;
|
||||
flex-direction: column;
|
||||
|
||||
.title {
|
||||
gap: .25rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
span:first-child {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.hint {
|
||||
color: #52607a;
|
||||
font-size: .8125rem;
|
||||
}
|
||||
}
|
||||
|
||||
.group {
|
||||
gap: .5rem;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
|
||||
.item {
|
||||
margin: 0;
|
||||
padding: .875rem;
|
||||
background: #fff;
|
||||
border: 1px solid rgba(148, 163, 184, .22);
|
||||
border-radius: .75rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.save {
|
||||
left: 1rem;
|
||||
right: 1rem;
|
||||
bottom: calc(.75rem + env(safe-area-inset-bottom, 0));
|
||||
position: fixed;
|
||||
}
|
||||
}
|
||||
|
||||
:global(html.theme-dark) {
|
||||
.registration-event-edit {
|
||||
.color-picker span,
|
||||
.roles .title .hint {
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.roles .group .item {
|
||||
color: #e2e8f0;
|
||||
background: rgba(15, 23, 42, .72);
|
||||
border-color: rgba(148, 163, 184, .16);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,297 @@
|
||||
<template>
|
||||
<div v-can="{ value: [gaoPermissionCodes.registrationEvent.read, gaoPermissionCodes.user.read], mode: 'and' }" class="registration-event-list">
|
||||
<article
|
||||
class="item"
|
||||
v-for="item in registrationEventList"
|
||||
:key="item.id"
|
||||
>
|
||||
<div class="main">
|
||||
<span class="color" :style="{ background: item.color || defaultColor }" />
|
||||
<div class="content">
|
||||
<div class="title">
|
||||
<strong v-text="item.name || '未命名事件'" />
|
||||
<t-tag
|
||||
size="small"
|
||||
theme="primary"
|
||||
:variant="isRegistrationEventActive(item) ? 'light' : 'outline'"
|
||||
:content="getStatusText(item)"
|
||||
/>
|
||||
</div>
|
||||
<p v-if="item.description" v-text="item.description" />
|
||||
<div class="meta">
|
||||
<span v-text="getLimitText(item)" />
|
||||
<span v-if="item.requirePhoto">需拍照</span>
|
||||
<span v-text="`${item.roleCodeList?.length || 0} 个角色`" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="roles">
|
||||
<t-tag
|
||||
v-for="roleCode in item.roleCodeList"
|
||||
:key="roleCode"
|
||||
theme="primary"
|
||||
variant="light-outline"
|
||||
:content="getRoleName(roleCode)"
|
||||
/>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<t-button
|
||||
v-can="gaoPermissionCodes.registrationEvent.update"
|
||||
theme="primary"
|
||||
variant="outline"
|
||||
size="small"
|
||||
@click="editRegistrationEvent(item)"
|
||||
content="编辑"
|
||||
>
|
||||
<template #icon>
|
||||
<t-icon name="edit-1" size="small" />
|
||||
</template>
|
||||
</t-button>
|
||||
<t-button
|
||||
v-can="gaoPermissionCodes.registrationEvent.delete"
|
||||
theme="danger"
|
||||
variant="outline"
|
||||
size="small"
|
||||
@click="removeRegistrationEvent(item)"
|
||||
content="删除"
|
||||
>
|
||||
<template #icon>
|
||||
<t-icon name="delete" size="small" />
|
||||
</template>
|
||||
</t-button>
|
||||
</div>
|
||||
</article>
|
||||
<div v-if="!registrationEventList.length" class="empty-block">暂无登记事件</div>
|
||||
<t-button v-can="gaoPermissionCodes.registrationEvent.create" class="create" theme="primary" @click="createRegistrationEvent">
|
||||
<template #icon>
|
||||
<t-icon name="add" />
|
||||
</template>
|
||||
新增登记事件
|
||||
</t-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Toast } from "tdesign-mobile-vue";
|
||||
import type { Role } from "timi-web";
|
||||
import RegistrationEventAPI from "@/api/RegistrationEventAPI";
|
||||
import GaoUserAPI from "@/api/GaoUserAPI";
|
||||
import type { RegistrationEvent, RegistrationLimitType } from "@/types/RegistrationEvent";
|
||||
import { gaoPermissionCodes } from "@/utils/gaoPermission";
|
||||
|
||||
defineOptions({
|
||||
name: "RegistrationEventList"
|
||||
});
|
||||
|
||||
const router = useRouter();
|
||||
const defaultColor = "#2f88ff";
|
||||
const registrationEventList = ref<RegistrationEvent[]>([]);
|
||||
const roleOptions = ref<Role[]>([]);
|
||||
|
||||
function createRegistrationEvent(): void {
|
||||
router.push("/management/registration-events/create");
|
||||
}
|
||||
|
||||
function editRegistrationEvent(item: RegistrationEvent): void {
|
||||
if (!item.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
router.push(`/management/registration-events/${item.id}/edit`);
|
||||
}
|
||||
|
||||
async function removeRegistrationEvent(item: RegistrationEvent): Promise<void> {
|
||||
if (!item.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await RegistrationEventAPI.remove(item.id);
|
||||
Toast({
|
||||
theme: "success",
|
||||
direction: "row",
|
||||
message: "登记事件已删除"
|
||||
});
|
||||
await loadData();
|
||||
} catch {
|
||||
Toast({
|
||||
theme: "error",
|
||||
direction: "row",
|
||||
message: "登记事件删除失败"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function isRegistrationEventActive(item: RegistrationEvent): boolean {
|
||||
return item.enabled !== false && (item.status || "ACTIVE") === "ACTIVE";
|
||||
}
|
||||
|
||||
function getStatusText(item: RegistrationEvent): string {
|
||||
if (item.status === "INACTIVE") {
|
||||
return "无效";
|
||||
}
|
||||
if (item.status === "DELETED") {
|
||||
return "已删除";
|
||||
}
|
||||
return item.enabled === false ? "停用" : "有效";
|
||||
}
|
||||
|
||||
function getLimitText(item: RegistrationEvent): string {
|
||||
const limitTextMap: Record<RegistrationLimitType, string> = {
|
||||
NONE: "不限次数",
|
||||
DAILY_HALF_DAY: "上午/下午各一次",
|
||||
NATURAL_DAY: "每天一次",
|
||||
INTERVAL_HOUR: `每隔 ${item.limitValue || 0} 小时`,
|
||||
NATURAL_MONTH: "每月一次",
|
||||
TOTAL_LIMIT: `累计限 ${item.limitValue || 0} 次`
|
||||
};
|
||||
return limitTextMap[item.limitType || "NONE"];
|
||||
}
|
||||
|
||||
function getRoleName(code: string): string {
|
||||
const role = roleOptions.value.find(item => item.code === code);
|
||||
return role?.name || code;
|
||||
}
|
||||
|
||||
async function loadData(): Promise<void> {
|
||||
const [eventResult, roles] = await Promise.all([
|
||||
RegistrationEventAPI.list({
|
||||
index: 0,
|
||||
size: 100
|
||||
}),
|
||||
GaoUserAPI.roleList()
|
||||
]);
|
||||
roleOptions.value = roles || [];
|
||||
registrationEventList.value = eventResult.list || [];
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
await loadData();
|
||||
} catch {
|
||||
Toast({
|
||||
theme: "error",
|
||||
direction: "row",
|
||||
message: "登记事件加载失败"
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.registration-event-list {
|
||||
gap: .875rem;
|
||||
padding: calc(var(--app-nav-height) + 1rem) 1rem 6rem 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.item {
|
||||
gap: .75rem;
|
||||
padding: 1rem;
|
||||
display: grid;
|
||||
border: 1px solid rgba(148, 163, 184, .16);
|
||||
background: rgba(255, 255, 255, .96);
|
||||
box-shadow: var(--td-shadow-1);
|
||||
border-radius: .75rem;
|
||||
|
||||
.main {
|
||||
gap: .75rem;
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: flex-start;
|
||||
|
||||
.color {
|
||||
width: .875rem;
|
||||
height: .875rem;
|
||||
flex: 0 0 auto;
|
||||
margin-top: .25rem;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
|
||||
.title {
|
||||
gap: .5rem;
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
|
||||
strong {
|
||||
overflow: hidden;
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
}
|
||||
|
||||
p {
|
||||
margin: .375rem 0 0 0;
|
||||
color: #52607a;
|
||||
font-size: .875rem;
|
||||
}
|
||||
|
||||
.meta {
|
||||
gap: .5rem;
|
||||
display: flex;
|
||||
margin-top: .5rem;
|
||||
flex-wrap: wrap;
|
||||
|
||||
span {
|
||||
color: #52607a;
|
||||
font-size: .75rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.roles {
|
||||
gap: .375rem;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.actions {
|
||||
gap: .5rem;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
|
||||
.empty-block {
|
||||
color: #52607a;
|
||||
padding: 2rem 1rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.create {
|
||||
left: 1rem;
|
||||
right: 1rem;
|
||||
bottom: calc(.75rem + env(safe-area-inset-bottom, 0));
|
||||
position: fixed;
|
||||
}
|
||||
}
|
||||
|
||||
:global(html.theme-dark) {
|
||||
.registration-event-list {
|
||||
.item {
|
||||
background: rgba(15, 23, 42, .88);
|
||||
border-color: rgba(148, 163, 184, .16);
|
||||
box-shadow: 0 .75rem 2rem rgba(2, 6, 23, .28);
|
||||
|
||||
.content {
|
||||
p,
|
||||
.meta span {
|
||||
color: #94a3b8;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.empty-block {
|
||||
color: #94a3b8;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,138 @@
|
||||
<template>
|
||||
<div v-can="gaoPermissionCodes.registrationEventRecord.read" class="registration-record-detail">
|
||||
<customer-profile-card v-if="record?.customer" class="customer-profile" :customer="record.customer" />
|
||||
<t-cell-group v-if="record" title="登记信息" theme="card">
|
||||
<t-cell title="登记事件" :note="record.registrationEvent?.name || '未知事件'" />
|
||||
<t-cell title="登记时间" :note="formatTime(record.registeredAt)" />
|
||||
<t-cell title="登记人" :note="getOperatorName(record)" />
|
||||
<t-cell title="客户编码" :note="record.customer?.code || record.customerId || '未知'" />
|
||||
<t-cell title="备注" :note="record.remark || '无'" />
|
||||
</t-cell-group>
|
||||
<section v-if="record" class="photo-panel">
|
||||
<photo-scroller
|
||||
:model-value="recordPhotoList"
|
||||
:show-add="false"
|
||||
:show-remove="false"
|
||||
title="登记照片"
|
||||
/>
|
||||
</section>
|
||||
<t-empty v-else :description="loading ? '正在加载登记事件记录' : '登记事件记录不存在'" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Time, useUserStore } from "timi-web";
|
||||
import RegistrationEventRecordAPI from "@/api/RegistrationEventRecordAPI";
|
||||
import CustomerProfileCard from "@/components/CustomerProfileCard.vue";
|
||||
import PhotoScroller from "@/components/PhotoScroller.vue";
|
||||
import { useNavBarStore } from "@/store/navBarStore";
|
||||
import type { RegistrationEventRecord } from "@/types/RegistrationEventRecord";
|
||||
import { getRegistrationEventRecordPhotoURL } from "@/utils/registrationEventRecord";
|
||||
import { gaoPermissionCodes } from "@/utils/gaoPermission";
|
||||
|
||||
defineOptions({
|
||||
name: "RegistrationEventRecordDetail"
|
||||
});
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const navBarStore = useNavBarStore();
|
||||
const userStore = useUserStore();
|
||||
const record = ref<RegistrationEventRecord>();
|
||||
const loading = ref(false);
|
||||
|
||||
const canUpdateRecord = computed(() => userStore.hasPermissions(gaoPermissionCodes.registrationEventRecord.update));
|
||||
const recordPhotoList = computed(() => {
|
||||
const url = getRegistrationEventRecordPhotoURL(record.value);
|
||||
return url ? [url] : [];
|
||||
});
|
||||
|
||||
async function loadRecord(): Promise<void> {
|
||||
const recordId = String(route.params.id || "");
|
||||
if (!recordId) {
|
||||
record.value = undefined;
|
||||
return;
|
||||
}
|
||||
|
||||
loading.value = true;
|
||||
try {
|
||||
record.value = await RegistrationEventRecordAPI.detail(recordId);
|
||||
} catch (err) {
|
||||
record.value = undefined;
|
||||
console.warn("登记事件记录加载失败", err);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function goEdit(): Promise<void> {
|
||||
const recordId = String(route.params.id || "");
|
||||
if (!recordId) {
|
||||
return;
|
||||
}
|
||||
await router.push(`/management/registration-event-records/${recordId}/edit`);
|
||||
}
|
||||
|
||||
function formatTime(value?: number): string {
|
||||
if (!value) {
|
||||
return "-";
|
||||
}
|
||||
|
||||
const date = Time.toDateObject(value);
|
||||
if (!date) {
|
||||
return "-";
|
||||
}
|
||||
return Time.formatDateTime(date);
|
||||
}
|
||||
|
||||
function getOperatorName(item: RegistrationEventRecord): string {
|
||||
return item.operator?.nick || item.operator?.name || "未知";
|
||||
}
|
||||
|
||||
watch(() => route.params.id, async () => {
|
||||
await loadRecord();
|
||||
}, { immediate: true });
|
||||
|
||||
onMounted(() => {
|
||||
navBarStore.clearRight();
|
||||
if (canUpdateRecord.value) {
|
||||
navBarStore.setRightText("编辑", "var(--td-brand-color)", goEdit);
|
||||
}
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
navBarStore.clearRight();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.registration-record-detail {
|
||||
gap: 1rem;
|
||||
min-height: 100vh;
|
||||
padding: calc(var(--app-nav-height) + 1rem) 0 1rem 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.customer-profile {
|
||||
margin: 0 1rem;
|
||||
}
|
||||
|
||||
.photo-panel {
|
||||
padding: 1rem;
|
||||
margin: 0 1rem;
|
||||
background: rgba(255, 255, 255, .92);
|
||||
box-shadow: var(--td-shadow-1);
|
||||
border-radius: 1.4rem;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
:global(html.theme-dark) {
|
||||
.registration-record-detail {
|
||||
.photo-panel {
|
||||
background: rgba(15, 23, 42, .88);
|
||||
box-shadow: 0 .75rem 2rem rgba(2, 6, 23, .32);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,630 @@
|
||||
<template>
|
||||
<div v-can="pageAccess" class="registration-record-edit">
|
||||
<t-form class="form" label-align="left">
|
||||
<t-form-item label="客户" required-mark>
|
||||
<t-input
|
||||
:model-value="customerDisplayText"
|
||||
class="customer"
|
||||
readonly
|
||||
borderless
|
||||
placeholder="选择或扫码客户"
|
||||
>
|
||||
<template #suffix-icon>
|
||||
<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"
|
||||
>
|
||||
<span v-text="scannerVisible ? '关闭扫码' : '扫码'" />
|
||||
</t-button>
|
||||
</div>
|
||||
</template>
|
||||
</t-input>
|
||||
</t-form-item>
|
||||
<div v-if="scannerVisible" class="code-scanner-wrapper">
|
||||
<code-scanner
|
||||
visible
|
||||
:status-text="scanStatus"
|
||||
active-text="正在识别客户编码,请将条码或二维码移入取景框。"
|
||||
@decode="handleDecodedText"
|
||||
@status-change="handleScanStatusChange"
|
||||
/>
|
||||
</div>
|
||||
<customer-profile-card
|
||||
v-if="selectedCustomer"
|
||||
class="customer-profile"
|
||||
:customer="selectedCustomer"
|
||||
/>
|
||||
<registration-event-selector
|
||||
v-model="selectedRegistrationEventIdList"
|
||||
class="registration-event-selector"
|
||||
:registration-event-list="registrationEventList"
|
||||
required-mark
|
||||
/>
|
||||
<t-form-item label="登记时间" content-align="right">
|
||||
<t-button
|
||||
theme="primary"
|
||||
variant="outline"
|
||||
size="extra-small"
|
||||
:content="registeredAtText || '此刻'"
|
||||
@click="openRegisteredAtPicker"
|
||||
/>
|
||||
</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 v-model="photoList" @add="capturePhoto" />
|
||||
</div>
|
||||
</t-form>
|
||||
<t-cell-group
|
||||
v-if="recentRecordList.length"
|
||||
title="最近登记事件列表"
|
||||
class="record-list"
|
||||
theme="card"
|
||||
>
|
||||
<t-cell
|
||||
v-for="item in recentRecordList"
|
||||
:key="item.id"
|
||||
:title="getRecordRegistrationEventName(item)"
|
||||
:description="formatTime(item.registeredAt)"
|
||||
arrow
|
||||
hover
|
||||
@click="goRecordDetail(item.id || '')"
|
||||
>
|
||||
<template #leftIcon>
|
||||
<t-image v-if="getRecordPhotoURL(item)" :src="getRecordPhotoURL(item)" alt="事件照片" fit="cover" class="record-photo" />
|
||||
</template>
|
||||
</t-cell>
|
||||
</t-cell-group>
|
||||
<div v-else class="empty">当前客户暂无事件记录</div>
|
||||
<customer-selector
|
||||
v-model="selectedCustomerId"
|
||||
v-model:visible="selectorVisible"
|
||||
@change="handleCustomerChange"
|
||||
/>
|
||||
<input
|
||||
ref="recordPhotoInputRef"
|
||||
class="file-input"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
multiple
|
||||
@change="handleRecordPhotoSelected"
|
||||
>
|
||||
<t-button theme="primary" class="save" :disabled="!canSubmit" :loading="saving" @click="submitRecord">
|
||||
<span v-text="saveButtonText" />
|
||||
</t-button>
|
||||
</div>
|
||||
<t-popup v-model="registeredAtPickerVisible" placement="bottom">
|
||||
<t-date-time-picker
|
||||
v-model="registeredAtDraftText"
|
||||
:default-value="registeredAtDraftText"
|
||||
:mode="['date', 'minute']"
|
||||
format="YYYY-MM-DD HH:mm"
|
||||
title="登记时间"
|
||||
@cancel="registeredAtPickerVisible = false"
|
||||
@confirm="confirmRegisteredAt"
|
||||
/>
|
||||
</t-popup>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { AttachmentAPI, Time, type Attachment } from "timi-web";
|
||||
import { Toast } from "tdesign-mobile-vue";
|
||||
import CustomerAPI from "@/api/CustomerAPI";
|
||||
import RegistrationEventAPI from "@/api/RegistrationEventAPI";
|
||||
import RegistrationEventRecordAPI from "@/api/RegistrationEventRecordAPI";
|
||||
import CodeScanner from "@/components/CodeScanner.vue";
|
||||
import CustomerSelector from "@/components/CustomerSelector.vue";
|
||||
import CustomerProfileCard from "@/components/CustomerProfileCard.vue";
|
||||
import PhotoScroller from "@/components/PhotoScroller.vue";
|
||||
import RegistrationEventSelector from "@/components/RegistrationEventSelector.vue";
|
||||
import { useSettingStore } from "@/store/settingStore";
|
||||
import type { Customer } from "@/types/Customer";
|
||||
import type { RegistrationEvent } from "@/types/RegistrationEvent";
|
||||
import type { RegistrationEventRecord } from "@/types/RegistrationEventRecord";
|
||||
import { getRegistrationEventRecordPhotoURL } from "@/utils/registrationEventRecord";
|
||||
import { gaoPermissionCodes } from "@/utils/gaoPermission";
|
||||
import { compressUploadImageList } from "@/utils/imageCompression";
|
||||
|
||||
defineOptions({
|
||||
name: "RegistrationEventRecordEdit"
|
||||
});
|
||||
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
const settingStore = useSettingStore();
|
||||
const customerCode = ref("");
|
||||
const scanCode = ref(customerCode.value);
|
||||
const selectorVisible = ref(false);
|
||||
const selectedCustomerId = ref("");
|
||||
const selectedCustomer = ref<Customer>();
|
||||
const registrationEventList = ref<RegistrationEvent[]>([]);
|
||||
const selectedRegistrationEventIdList = ref<string[]>([]);
|
||||
const recentRecordList = ref<RegistrationEventRecord[]>([]);
|
||||
const registeredAtText = ref("");
|
||||
const registeredAtDraftText = ref("");
|
||||
const registeredAtPickerVisible = ref(false);
|
||||
const note = ref("");
|
||||
const recordPhotoInputRef = ref<HTMLInputElement>();
|
||||
const photoItemList = ref<EditablePhoto[]>([]);
|
||||
const scannerVisible = ref(false);
|
||||
const scanStatus = ref("点击扫码开始识别。");
|
||||
const saving = ref(false);
|
||||
const editingRecord = ref<RegistrationEventRecord>();
|
||||
let latestDecodedText = "";
|
||||
|
||||
type EditablePhoto = {
|
||||
attachment?: Partial<Attachment>;
|
||||
tempFileId?: string;
|
||||
url: string;
|
||||
};
|
||||
|
||||
const routeId = computed(() => String(route.params.id || ""));
|
||||
const isEditMode = computed(() => !!routeId.value);
|
||||
const pageAccess = computed(() => isEditMode.value ? gaoPermissionCodes.registrationEventRecord.update : gaoPermissionCodes.registrationEventRecord.create);
|
||||
const saveButtonText = computed(() => isEditMode.value ? "保存记录" : "提交");
|
||||
const canSubmit = computed(() => !!selectedCustomer.value?.id && 0 < selectedRegistrationEventIdList.value.length);
|
||||
const customerDisplayText = computed(() => selectedCustomer.value?.name || selectedCustomer.value?.code || customerCode.value);
|
||||
const photoList = computed<string[]>({
|
||||
get() {
|
||||
return photoItemList.value.map(item => item.url);
|
||||
},
|
||||
set(value) {
|
||||
const urlSet = new Set(value);
|
||||
photoItemList.value = photoItemList.value.filter(item => urlSet.has(item.url));
|
||||
}
|
||||
});
|
||||
const registrationEventNameMap = computed(() => {
|
||||
const map = new Map<string, string>();
|
||||
for (const item of registrationEventList.value) {
|
||||
if (item.id) {
|
||||
map.set(item.id, item.name || "未命名事件");
|
||||
}
|
||||
}
|
||||
return map;
|
||||
});
|
||||
|
||||
// ---------- 页面数据 ----------
|
||||
|
||||
async function loadPageData(): Promise<void> {
|
||||
registrationEventList.value = await RegistrationEventAPI.enabledList();
|
||||
if (isEditMode.value) {
|
||||
await loadRecordForEdit();
|
||||
return;
|
||||
}
|
||||
|
||||
selectedRegistrationEventIdList.value = registrationEventList.value.slice(0, 1).map(item => item.id).filter((item): item is string => Boolean(item));
|
||||
}
|
||||
|
||||
async function loadRecordForEdit(): Promise<void> {
|
||||
const record = await RegistrationEventRecordAPI.detail(routeId.value);
|
||||
editingRecord.value = record;
|
||||
appendRecordRegistrationEvent(record);
|
||||
selectedCustomer.value = record.customer;
|
||||
customerCode.value = record.customer?.code || record.customerId || "";
|
||||
selectedCustomerId.value = record.customer?.id || "";
|
||||
selectedRegistrationEventIdList.value = record.registrationEventId ? [record.registrationEventId] : [];
|
||||
registeredAtText.value = record.registeredAt ? Time.formatDateTime(new Date(record.registeredAt)) : "";
|
||||
note.value = record.remark || "";
|
||||
photoItemList.value = (record.attachmentList || [])
|
||||
.filter(item => item.attachType === "PHOTO" || !item.attachType)
|
||||
.filter(item => !!item.id)
|
||||
.map(item => ({
|
||||
attachment: item,
|
||||
url: AttachmentAPI.getReadURL(item.id || "")
|
||||
}));
|
||||
await loadRecentRecordList();
|
||||
}
|
||||
|
||||
function appendRecordRegistrationEvent(record: RegistrationEventRecord): void {
|
||||
if (!record.registrationEvent?.id || registrationEventList.value.some(item => item.id === record.registrationEvent?.id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
registrationEventList.value = [record.registrationEvent, ...registrationEventList.value];
|
||||
}
|
||||
|
||||
// ---------- 扫码 ----------
|
||||
|
||||
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> {
|
||||
customerCode.value = scanCode.value.trim();
|
||||
scanStatus.value = `已识别 ${customerCode.value},正在加载客户档案。`;
|
||||
await loadSelectedCustomer();
|
||||
if (!selectedCustomer.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
scanStatus.value = `已识别 ${selectedCustomer.value.code || customerCode.value},已自动选中 ${selectedCustomer.value.name || "未命名客户"}。`;
|
||||
scannerVisible.value = false;
|
||||
}
|
||||
|
||||
function handleScanStatusChange(value: string): void {
|
||||
scanStatus.value = value;
|
||||
}
|
||||
|
||||
// ---------- 客户 ----------
|
||||
|
||||
async function handleCustomerChange(id: string): Promise<void> {
|
||||
const customer = await CustomerAPI.detail(id);
|
||||
selectedCustomer.value = customer;
|
||||
selectedCustomerId.value = customer.id || "";
|
||||
customerCode.value = customer.code || "";
|
||||
scanCode.value = customer.code || "";
|
||||
await loadRecentRecordList();
|
||||
}
|
||||
|
||||
async function goRecordDetail(id: string): Promise<void> {
|
||||
if (!id) {
|
||||
return;
|
||||
}
|
||||
await router.push(`/management/registration-event-records/${id}`);
|
||||
}
|
||||
|
||||
async function loadSelectedCustomer(): Promise<void> {
|
||||
const code = customerCode.value.trim();
|
||||
if (!code) {
|
||||
selectedCustomer.value = undefined;
|
||||
selectedCustomerId.value = "";
|
||||
recentRecordList.value = [];
|
||||
return;
|
||||
}
|
||||
|
||||
const customer = await CustomerAPI.nullableDetailByCode(code);
|
||||
if (!customer) {
|
||||
selectedCustomer.value = undefined;
|
||||
selectedCustomerId.value = "";
|
||||
recentRecordList.value = [];
|
||||
scanStatus.value = `客户 ${code} 不存在,请先建档。`;
|
||||
return;
|
||||
}
|
||||
|
||||
selectedCustomer.value = customer;
|
||||
selectedCustomerId.value = customer.id || "";
|
||||
await loadRecentRecordList();
|
||||
}
|
||||
|
||||
async function resolveSubmitCustomer(): Promise<Customer | undefined> {
|
||||
if (!validateForm()) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (selectedCustomer.value?.id) {
|
||||
return selectedCustomer.value;
|
||||
}
|
||||
|
||||
showToast("请先选择或扫码已有客户", "warning");
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async function loadRecentRecordList(): Promise<void> {
|
||||
const customerId = selectedCustomer.value?.id;
|
||||
if (!customerId) {
|
||||
recentRecordList.value = [];
|
||||
return;
|
||||
}
|
||||
|
||||
const endRegisteredAt = Time.toTimestamp(new Date());
|
||||
recentRecordList.value = (await RegistrationEventRecordAPI.rangeList({
|
||||
customerId,
|
||||
beginRegisteredAt: 0,
|
||||
endRegisteredAt
|
||||
}))
|
||||
.sort((left, right) => (right.registeredAt || 0) - (left.registeredAt || 0))
|
||||
.slice(0, 5);
|
||||
}
|
||||
|
||||
function getRecordRegistrationEventName(record: RegistrationEventRecord): string {
|
||||
return record.registrationEvent?.name || (record.registrationEventId ? registrationEventNameMap.value.get(record.registrationEventId) : "") || "未知事件";
|
||||
}
|
||||
|
||||
function getRecordPhotoURL(record: RegistrationEventRecord): string {
|
||||
return getRegistrationEventRecordPhotoURL(record);
|
||||
}
|
||||
|
||||
// ---------- 照片 ----------
|
||||
|
||||
function capturePhoto(): void {
|
||||
recordPhotoInputRef.value?.click();
|
||||
}
|
||||
|
||||
async function handleRecordPhotoSelected(event: Event): Promise<void> {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const fileList = Array.from(input.files || []);
|
||||
if (!fileList.length) {
|
||||
return;
|
||||
}
|
||||
const compressedFileList = await compressUploadImageList(fileList);
|
||||
const tempFileList = await AttachmentAPI.uploadTemp(compressedFileList);
|
||||
for (const item of tempFileList) {
|
||||
if (!item.id) {
|
||||
continue;
|
||||
}
|
||||
photoItemList.value.push({
|
||||
tempFileId: item.id,
|
||||
url: AttachmentAPI.getTempReadURL(item.id)
|
||||
});
|
||||
}
|
||||
input.value = "";
|
||||
}
|
||||
|
||||
// ---------- 提交 ----------
|
||||
|
||||
function openRegisteredAtPicker(): void {
|
||||
registeredAtDraftText.value = registeredAtText.value || Time.formatDateTime(new Date());
|
||||
registeredAtPickerVisible.value = true;
|
||||
}
|
||||
|
||||
function confirmRegisteredAt(): void {
|
||||
registeredAtText.value = registeredAtDraftText.value;
|
||||
registeredAtPickerVisible.value = false;
|
||||
}
|
||||
|
||||
async function submitRecord(): Promise<void> {
|
||||
const submitCustomer = await resolveSubmitCustomer();
|
||||
if (!submitCustomer?.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
saving.value = true;
|
||||
try {
|
||||
if (isEditMode.value) {
|
||||
await updateRecord();
|
||||
return;
|
||||
}
|
||||
|
||||
for (const registrationEventId of selectedRegistrationEventIdList.value) {
|
||||
await RegistrationEventRecordAPI.create({
|
||||
registrationEventId,
|
||||
customerId: submitCustomer.id,
|
||||
remark: note.value || "客户现场登记",
|
||||
registeredAt: buildRegisteredAt(),
|
||||
attachmentIdList: buildTempFileIdList()
|
||||
});
|
||||
}
|
||||
note.value = "";
|
||||
photoItemList.value = [];
|
||||
await loadRecentRecordList();
|
||||
await settingStore.playRegisterSuccessSound();
|
||||
showToast("登记已提交");
|
||||
await router.replace("/customers");
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function validateForm(): boolean {
|
||||
if (!selectedCustomer.value?.id) {
|
||||
showToast("请先选择或扫码客户", "warning");
|
||||
return false;
|
||||
}
|
||||
if (!selectedRegistrationEventIdList.value.length) {
|
||||
showToast("请至少选择一个登记事件", "warning");
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async function updateRecord(): Promise<void> {
|
||||
const submitCustomer = selectedCustomer.value;
|
||||
if (!editingRecord.value?.id || !submitCustomer?.id) {
|
||||
showToast("登记事件记录不存在", "warning");
|
||||
return;
|
||||
}
|
||||
|
||||
await RegistrationEventRecordAPI.update({
|
||||
...editingRecord.value,
|
||||
customerId: submitCustomer.id,
|
||||
registrationEventId: selectedRegistrationEventIdList.value[0],
|
||||
remark: note.value,
|
||||
registeredAt: buildRegisteredAt(),
|
||||
attachmentList: buildPhotoAttachmentList()
|
||||
});
|
||||
showToast("记录已保存");
|
||||
await router.replace(`/management/registration-event-records/${editingRecord.value.id}`);
|
||||
}
|
||||
|
||||
function buildTempFileIdList(): string[] {
|
||||
return photoItemList.value.map(item => item.tempFileId).filter((item): item is string => !!item);
|
||||
}
|
||||
|
||||
function buildPhotoAttachmentList(): Array<Partial<Attachment>> {
|
||||
return photoItemList.value.map((item) => {
|
||||
if (item.attachment) {
|
||||
return item.attachment;
|
||||
}
|
||||
|
||||
return {
|
||||
tempFileId: item.tempFileId,
|
||||
attachType: "PHOTO"
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function buildRegisteredAt(): number {
|
||||
return Time.toTimestamp(parseDateTime(registeredAtText.value));
|
||||
}
|
||||
|
||||
function showToast(message: string, theme: "success" | "warning" = "success"): void {
|
||||
Toast({
|
||||
theme,
|
||||
direction: "row",
|
||||
message
|
||||
});
|
||||
}
|
||||
|
||||
function formatTime(value?: number): string {
|
||||
if (!value) {
|
||||
return "-";
|
||||
}
|
||||
|
||||
const date = Time.toDateObject(value);
|
||||
if (!date) {
|
||||
return "-";
|
||||
}
|
||||
return Time.formatDateTime(date);
|
||||
}
|
||||
|
||||
function parseDateTime(value: string): Date {
|
||||
if (!value) {
|
||||
return new Date();
|
||||
}
|
||||
|
||||
const [dateText, timeText = "00:00"] = value.split(" ");
|
||||
const [year, month, day] = dateText.split("-").map(Number);
|
||||
const [hour, minute] = timeText.split(":").map(Number);
|
||||
return new Date(year, month - 1, day, hour || 0, minute || 0, 0, 0);
|
||||
}
|
||||
|
||||
onMounted(loadPageData);
|
||||
|
||||
watch(customerCode, async (value) => {
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
|
||||
scanCode.value = value;
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.registration-record-edit {
|
||||
gap: 1rem;
|
||||
display: flex;
|
||||
padding: calc(var(--app-nav-height) + 1rem) 0 4rem 0;
|
||||
flex-direction: column;
|
||||
|
||||
.form,
|
||||
.record-panel {
|
||||
background: rgba(255, 255, 255, .92);
|
||||
box-shadow: var(--td-shadow-1);
|
||||
border-radius: 1.4rem;
|
||||
}
|
||||
|
||||
.form {
|
||||
padding: .5rem 0;
|
||||
background: rgba(255, 255, 255, .92);
|
||||
box-shadow: var(--td-shadow-1);
|
||||
border-radius: 1.4rem;
|
||||
|
||||
.customer {
|
||||
|
||||
.actions {
|
||||
gap: .5rem;
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
|
||||
.code-scanner-wrapper,
|
||||
.photo-part {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.textarea {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.customer-profile {
|
||||
margin: 1rem;
|
||||
}
|
||||
|
||||
.registration-event-selector {
|
||||
padding: 0 1rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.record-photo {
|
||||
width: 3.5rem;
|
||||
height: 3.5rem;
|
||||
border-radius: .875rem;
|
||||
}
|
||||
|
||||
.empty {
|
||||
color: #52607a;
|
||||
margin: 0 1rem;
|
||||
padding: 1rem;
|
||||
text-align: center;
|
||||
background: #f7faff;
|
||||
border-radius: 1rem;
|
||||
}
|
||||
|
||||
.save {
|
||||
left: 1rem;
|
||||
right: 1rem;
|
||||
bottom: calc(.75rem + env(safe-area-inset-bottom, 0));
|
||||
position: fixed;
|
||||
}
|
||||
|
||||
.file-input {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
:global(html.theme-dark) {
|
||||
.registration-record-edit {
|
||||
:deep(.form-item .t-form__label),
|
||||
.selector-text p,
|
||||
.empty-block {
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.form,
|
||||
.record-panel {
|
||||
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);
|
||||
}
|
||||
|
||||
.empty-block {
|
||||
background: rgba(30, 41, 59, .9);
|
||||
}
|
||||
|
||||
.selector-text strong {
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
.selector-action {
|
||||
color: #93c5fd;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,629 @@
|
||||
<template>
|
||||
<div ref="pageRef" v-can="gaoPermissionCodes.registrationEventRecord.read" class="registration-record-list">
|
||||
<div class="search-bar">
|
||||
<t-input
|
||||
v-model.trim="keyword"
|
||||
class="search-input"
|
||||
type="search"
|
||||
borderless
|
||||
placeholder="搜索客户、编码、事件、备注、登记人"
|
||||
/>
|
||||
<div class="filter-line">
|
||||
<t-dropdown-menu class="filter-menu">
|
||||
<t-dropdown-item
|
||||
v-model="dateFilterType"
|
||||
:label="dateFilterLabel"
|
||||
:options="dateOptions"
|
||||
@change="selectDateFilter"
|
||||
/>
|
||||
<t-dropdown-item
|
||||
v-model="actionFilter"
|
||||
:label="eventFilterLabel"
|
||||
options-columns="2"
|
||||
:options="actionOptions"
|
||||
multiple
|
||||
/>
|
||||
</t-dropdown-menu>
|
||||
</div>
|
||||
</div>
|
||||
<t-cell-group v-if="isCustomRange" class="time-range" theme="card">
|
||||
<t-cell
|
||||
title="开始时间"
|
||||
:note="dateRange[0] || '-'"
|
||||
@click="startVisible = true"
|
||||
/>
|
||||
<t-cell
|
||||
title="结束时间"
|
||||
:note="dateRange[1] || '-'"
|
||||
@click="endVisible = true"
|
||||
/>
|
||||
</t-cell-group>
|
||||
<div class="summary">
|
||||
<div v-text="summaryText" />
|
||||
<div v-text="summaryBehaviorText" />
|
||||
<div v-text="summaryTotalText" />
|
||||
</div>
|
||||
<t-cell-group
|
||||
v-if="recordList.length"
|
||||
class="record-list"
|
||||
theme="card"
|
||||
>
|
||||
<t-swipe-cell
|
||||
v-for="item in recordList"
|
||||
:key="item.id"
|
||||
class="item"
|
||||
>
|
||||
<t-cell
|
||||
:title="getRecordRegistrationEventName(item)"
|
||||
:description="getRecordCustomerName(item)"
|
||||
:note="formatTime(item.registeredAt)"
|
||||
arrow
|
||||
hover
|
||||
@click="goDetail(item.id || '')"
|
||||
>
|
||||
<template #leftIcon>
|
||||
<t-image
|
||||
v-if="getRecordPhotoURL(item)"
|
||||
:src="getRecordPhotoURL(item)"
|
||||
:alt="getRecordRegistrationEventName(item)"
|
||||
fit="cover"
|
||||
class="photo"
|
||||
/>
|
||||
<div v-else class="photo fallback" v-text="getRecordCustomerInitial(item)" />
|
||||
</template>
|
||||
</t-cell>
|
||||
<template v-if="canDeleteRecord" #right>
|
||||
<t-button
|
||||
class="delete"
|
||||
theme="danger"
|
||||
:loading="deletingId === item.id"
|
||||
@click.stop="confirmRemoveRecord(item)"
|
||||
>
|
||||
删除
|
||||
</t-button>
|
||||
</template>
|
||||
</t-swipe-cell>
|
||||
</t-cell-group>
|
||||
<div v-else class="empty-block" v-text="loading ? '正在加载登记事件记录' : '暂无登记事件记录'" />
|
||||
<div v-if="recordList.length" class="load-state" v-text="loadStateText" />
|
||||
</div>
|
||||
<t-button
|
||||
v-can="gaoPermissionCodes.registrationEventRecord.read"
|
||||
class="export"
|
||||
theme="primary"
|
||||
:loading="exporting"
|
||||
@click="exportExcel"
|
||||
>
|
||||
<template #icon>
|
||||
<t-icon name="download" />
|
||||
</template>
|
||||
导出 Excel
|
||||
</t-button>
|
||||
<t-popup v-model="startVisible" placement="bottom">
|
||||
<t-date-time-picker
|
||||
:default-value="dateRange[0]"
|
||||
format="YYYY-MM-DD HH:mm"
|
||||
:mode="['date', 'minute']"
|
||||
title="开始时间"
|
||||
v-model="dateRange[0]"
|
||||
@confirm="startVisible = false"
|
||||
/>
|
||||
</t-popup>
|
||||
<t-popup v-model="endVisible" placement="bottom">
|
||||
<t-date-time-picker
|
||||
:default-value="dateRange[1]"
|
||||
:mode="['date', 'minute']"
|
||||
format="YYYY-MM-DD HH:mm"
|
||||
title="结束时间"
|
||||
v-model="dateRange[1]"
|
||||
@confirm="endVisible = false"
|
||||
/>
|
||||
</t-popup>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { DialogPlugin, Toast, type DropdownValue } from "tdesign-mobile-vue";
|
||||
import { AttachmentAPI, Scroller, Time, Toolkit, useUserStore } from "timi-web";
|
||||
import RegistrationEventAPI from "@/api/RegistrationEventAPI";
|
||||
import RegistrationEventRecordAPI from "@/api/RegistrationEventRecordAPI";
|
||||
import { useNavBarStore } from "@/store/navBarStore";
|
||||
import type { RegistrationEvent } from "@/types/RegistrationEvent";
|
||||
import type { RegistrationEventRecordListItemView, RegistrationEventRecordRangeReq } from "@/types/RegistrationEventRecord";
|
||||
import { gaoPermissionCodes } from "@/utils/gaoPermission";
|
||||
|
||||
type DateFilterType = "today" | "yesterday" | "beforeYesterday" | "lastWeek" | "all" | "custom";
|
||||
|
||||
defineOptions({
|
||||
name: "RegistrationEventRecordList"
|
||||
});
|
||||
|
||||
const pageSize = 20;
|
||||
const scrollListenerName = "RegistrationEventRecordList";
|
||||
const router = useRouter();
|
||||
const navBarStore = useNavBarStore();
|
||||
const userStore = useUserStore();
|
||||
const dateOptions: Array<{ label: string; value: DateFilterType }> = [
|
||||
{ label: "今天", value: "today" },
|
||||
{ label: "昨天", value: "yesterday" },
|
||||
{ label: "前天", value: "beforeYesterday" },
|
||||
{ label: "上周", value: "lastWeek" },
|
||||
{ label: "全部", value: "all" },
|
||||
{ label: "自定义范围", value: "custom" }
|
||||
];
|
||||
|
||||
// ---------- 筛选 ----------
|
||||
|
||||
const keyword = ref("");
|
||||
const dateFilterType = ref<DateFilterType>("today");
|
||||
const dateRange = ref(buildPresetRange("today"));
|
||||
const actionFilter = ref<string[]>([]);
|
||||
const registrationEventList = ref<RegistrationEvent[]>([]);
|
||||
const startVisible = ref(false);
|
||||
const endVisible = ref(false);
|
||||
const pageRef = ref<HTMLElement>();
|
||||
let keywordTimer: number | undefined;
|
||||
let initialized = false;
|
||||
|
||||
function getPageScrollElement(): HTMLElement | null {
|
||||
return pageRef.value?.closest(".pages") || null;
|
||||
}
|
||||
|
||||
const canCreateRecord = computed(() => userStore.hasPermissions(gaoPermissionCodes.registrationEventRecord.create));
|
||||
const isCustomRange = computed(() => dateFilterType.value === "custom");
|
||||
const dateFilterLabel = computed(() => dateOptions.find(item => item.value === dateFilterType.value)?.label || "今天");
|
||||
const actionOptions = computed(() => registrationEventList.value.map(item => ({
|
||||
label: item.name || "未命名事件",
|
||||
value: item.id || ""
|
||||
})).filter(item => item.value));
|
||||
const eventFilterLabel = computed(() => {
|
||||
if (!actionFilter.value.length || actionFilter.value.length === registrationEventList.value.length) {
|
||||
return "全部事件";
|
||||
}
|
||||
|
||||
return registrationEventList.value
|
||||
.filter(item => item.id && actionFilter.value.includes(item.id))
|
||||
.map(item => item.name || "未命名事件")
|
||||
.join("、");
|
||||
});
|
||||
const summaryBehaviorText = computed(() => `事件:${eventFilterLabel.value}`);
|
||||
const canDeleteRecord = computed(() => userStore.hasPermissions(gaoPermissionCodes.registrationEventRecord.delete));
|
||||
const summaryText = computed(() => {
|
||||
if (!isCustomRange.value) {
|
||||
const current = dateOptions.find(item => item.value === dateFilterType.value);
|
||||
return `范围:${current?.label || "今天"}|${dateRange.value[0] || "不限"}`;
|
||||
}
|
||||
|
||||
return `范围:${dateRange.value[0] || "-"} ~ ${dateRange.value[1] || "-"}`;
|
||||
});
|
||||
|
||||
watch(keyword, () => {
|
||||
if (!initialized) {
|
||||
return;
|
||||
}
|
||||
window.clearTimeout(keywordTimer);
|
||||
keywordTimer = window.setTimeout(async () => {
|
||||
await reloadRecords();
|
||||
}, 300);
|
||||
});
|
||||
|
||||
watch(actionFilter, async () => {
|
||||
if (!initialized) {
|
||||
return;
|
||||
}
|
||||
await reloadRecords();
|
||||
});
|
||||
|
||||
watch(dateRange, async (value) => {
|
||||
if (!initialized || dateFilterType.value !== "custom") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (value[0] && value[1] && value[1] < value[0]) {
|
||||
dateRange.value = [value[1], value[0]];
|
||||
return;
|
||||
}
|
||||
|
||||
await reloadRecords();
|
||||
}, { deep: true });
|
||||
|
||||
// ---------- 列表分页 ----------
|
||||
|
||||
const pageIndex = ref(0);
|
||||
const totalCount = ref(0);
|
||||
const recordList = ref<RegistrationEventRecordListItemView[]>([]);
|
||||
const loading = ref(false);
|
||||
const noMore = ref(false);
|
||||
let loadToken = 0;
|
||||
|
||||
const summaryTotalText = computed(() => `共 ${totalCount.value} 条数据,已加载 ${recordList.value.length} 条`);
|
||||
const loadStateText = computed(() => {
|
||||
if (loading.value) {
|
||||
return "正在加载更多";
|
||||
}
|
||||
return noMore.value ? "没有更多记录" : "上拉加载更多";
|
||||
});
|
||||
|
||||
async function reloadRecords(): Promise<void> {
|
||||
pageIndex.value = 0;
|
||||
totalCount.value = 0;
|
||||
recordList.value = [];
|
||||
noMore.value = false;
|
||||
await loadRecords();
|
||||
}
|
||||
|
||||
async function loadRecords(): Promise<void> {
|
||||
if (loading.value || noMore.value) {
|
||||
return;
|
||||
}
|
||||
const token = loadToken + 1;
|
||||
loadToken = token;
|
||||
loading.value = true;
|
||||
try {
|
||||
const result = await RegistrationEventRecordAPI.rangePage({
|
||||
index: pageIndex.value,
|
||||
size: pageSize,
|
||||
equalsExample: buildRangeReq()
|
||||
});
|
||||
if (token !== loadToken) {
|
||||
return;
|
||||
}
|
||||
recordList.value = recordList.value.concat(result.list || []);
|
||||
totalCount.value = result.total || 0;
|
||||
pageIndex.value += 1;
|
||||
noMore.value = recordList.value.length >= totalCount.value || (result.list || []).length < pageSize;
|
||||
} finally {
|
||||
if (token === loadToken) {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 操作 ----------
|
||||
|
||||
const exporting = ref(false);
|
||||
const deletingId = ref("");
|
||||
|
||||
async function loadActions(): Promise<void> {
|
||||
registrationEventList.value = await RegistrationEventAPI.enabledList();
|
||||
actionFilter.value = registrationEventList.value.map(item => item.id).filter((item): item is string => Boolean(item));
|
||||
}
|
||||
|
||||
async function selectDateFilter(type: DropdownValue): Promise<void> {
|
||||
if (Array.isArray(type)) {
|
||||
return;
|
||||
}
|
||||
|
||||
dateFilterType.value = String(type) as DateFilterType;
|
||||
if (dateFilterType.value !== "custom") {
|
||||
dateRange.value = buildPresetRange(dateFilterType.value);
|
||||
await reloadRecords();
|
||||
}
|
||||
}
|
||||
|
||||
async function exportExcel(): Promise<void> {
|
||||
if (exporting.value) {
|
||||
return;
|
||||
}
|
||||
exporting.value = true;
|
||||
try {
|
||||
const blob = await RegistrationEventRecordAPI.rangeExport(buildRangeReq());
|
||||
Toolkit.downloadBlob(blob, `登记事件记录-${Time.formatDate(new Date())}.xlsx`);
|
||||
showToast("导出已开始");
|
||||
} finally {
|
||||
exporting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function goDetail(id: string): Promise<void> {
|
||||
if (!id) {
|
||||
return;
|
||||
}
|
||||
await router.push(`/management/registration-event-records/${id}`);
|
||||
}
|
||||
|
||||
async function goCreate(): Promise<void> {
|
||||
await router.push("/management/registration-event-records/create");
|
||||
}
|
||||
|
||||
async function confirmRemoveRecord(record: RegistrationEventRecordListItemView): Promise<void> {
|
||||
if (!record.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
const recordId = record.id;
|
||||
const dialog = DialogPlugin.confirm({
|
||||
title: "删除登记记录",
|
||||
content: `确定删除 ${getRecordCustomerName(record)} 的 ${getRecordRegistrationEventName(record)} 记录吗?删除后无法恢复。`,
|
||||
cancelBtn: "取消",
|
||||
confirmBtn: "删除",
|
||||
onCancel() {
|
||||
dialog.destroy();
|
||||
},
|
||||
async onConfirm() {
|
||||
dialog.destroy();
|
||||
await removeRecord(recordId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function removeRecord(id: string): Promise<void> {
|
||||
if (deletingId.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
deletingId.value = id;
|
||||
try {
|
||||
await RegistrationEventRecordAPI.remove(id);
|
||||
recordList.value = recordList.value.filter(item => item.id !== id);
|
||||
totalCount.value = Math.max(0, totalCount.value - 1);
|
||||
showToast("登记记录已删除");
|
||||
} finally {
|
||||
deletingId.value = "";
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 格式化 ----------
|
||||
|
||||
function getRecordRegistrationEventName(record: RegistrationEventRecordListItemView): string {
|
||||
return record.registrationEventName || "未知事件";
|
||||
}
|
||||
|
||||
function getRecordCustomerName(record: RegistrationEventRecordListItemView): string {
|
||||
return record.customerName || "未知客户";
|
||||
}
|
||||
|
||||
function getRecordPhotoURL(record: RegistrationEventRecordListItemView): string {
|
||||
if (!record.customerPhotoAttachmentId) {
|
||||
return "";
|
||||
}
|
||||
return AttachmentAPI.getReadURL(record.customerPhotoAttachmentId);
|
||||
}
|
||||
|
||||
function getRecordCustomerInitial(record: RegistrationEventRecordListItemView): string {
|
||||
return (record.customerName || "客").slice(0, 1);
|
||||
}
|
||||
|
||||
function formatTime(value?: number): string {
|
||||
if (!value) {
|
||||
return "-";
|
||||
}
|
||||
|
||||
const date = Time.toDateObject(value);
|
||||
if (!date) {
|
||||
return "-";
|
||||
}
|
||||
return Time.formatDateTime(date);
|
||||
}
|
||||
|
||||
function buildRangeReq(): RegistrationEventRecordRangeReq {
|
||||
const [beginRegisteredAt, endRegisteredAt] = buildUnixRange();
|
||||
const selectedAllEvent = !actionFilter.value.length || actionFilter.value.length === registrationEventList.value.length;
|
||||
return {
|
||||
beginRegisteredAt,
|
||||
endRegisteredAt,
|
||||
keyword: keyword.value.trim() || undefined,
|
||||
registrationEventIdList: selectedAllEvent ? undefined : actionFilter.value
|
||||
};
|
||||
}
|
||||
|
||||
function buildPresetRange(type: Exclude<DateFilterType, "custom">): [string, string] {
|
||||
if (type === "all") {
|
||||
return ["", ""];
|
||||
}
|
||||
if (type === "lastWeek") {
|
||||
const current = new Date();
|
||||
current.setHours(0, 0, 0, 0);
|
||||
const day = current.getDay() || 7;
|
||||
const thisWeekMonday = new Date(current);
|
||||
thisWeekMonday.setDate(current.getDate() - day + 1);
|
||||
const lastWeekMonday = new Date(thisWeekMonday);
|
||||
lastWeekMonday.setDate(thisWeekMonday.getDate() - 7);
|
||||
const lastWeekSunday = new Date(thisWeekMonday);
|
||||
lastWeekSunday.setDate(thisWeekMonday.getDate() - 1);
|
||||
return [`${Time.formatDate(lastWeekMonday)} 00:00`, `${Time.formatDate(lastWeekSunday)} 23:59`];
|
||||
}
|
||||
|
||||
const offsetMap: Record<Exclude<DateFilterType, "all" | "custom" | "lastWeek">, number> = {
|
||||
today: 0,
|
||||
yesterday: 1,
|
||||
beforeYesterday: 2
|
||||
};
|
||||
const date = new Date();
|
||||
date.setHours(0, 0, 0, 0);
|
||||
date.setDate(date.getDate() - offsetMap[type]);
|
||||
const value = `${Time.formatDate(date)} 00:00`;
|
||||
return [value, `${Time.formatDate(date)} 23:59`];
|
||||
}
|
||||
|
||||
function buildUnixRange(): [number, number] {
|
||||
if (dateFilterType.value === "all") {
|
||||
return [0, Time.toTimestamp(new Date())];
|
||||
}
|
||||
|
||||
return [
|
||||
Time.toTimestamp(parseDateTime(dateRange.value[0], false)),
|
||||
Time.toTimestamp(parseDateTime(dateRange.value[1], true))
|
||||
];
|
||||
}
|
||||
|
||||
function parseDateTime(value: string, isEnd: boolean): Date {
|
||||
if (!value) {
|
||||
return isEnd ? new Date() : new Date(0);
|
||||
}
|
||||
const [dateText, timeText = isEnd ? "23:59" : "00:00"] = value.split(" ");
|
||||
const [year, month, day] = dateText.split("-").map(Number);
|
||||
const [hour, minute] = timeText.split(":").map(Number);
|
||||
return new Date(year, month - 1, day, hour || 0, minute || 0, isEnd ? 59 : 0, 0);
|
||||
}
|
||||
|
||||
function showToast(message: string, theme: "success" | "warning" = "success"): void {
|
||||
Toast({
|
||||
message,
|
||||
theme
|
||||
});
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
navBarStore.clearRight();
|
||||
if (canCreateRecord.value) {
|
||||
navBarStore.setRightText("添加", "var(--td-brand-color)", goCreate);
|
||||
}
|
||||
await loadActions();
|
||||
initialized = true;
|
||||
await reloadRecords();
|
||||
Scroller.addBottomListener(scrollListenerName, loadRecords, {
|
||||
canLoad: () => !loading.value && !noMore.value,
|
||||
getElement: getPageScrollElement
|
||||
});
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
navBarStore.clearRight();
|
||||
window.clearTimeout(keywordTimer);
|
||||
Scroller.removeListener(scrollListenerName);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.registration-record-list {
|
||||
gap: 1rem;
|
||||
display: flex;
|
||||
padding: calc(var(--app-nav-height)) 0 6rem 0;
|
||||
flex-direction: column;
|
||||
|
||||
.search-bar {
|
||||
top: var(--app-nav-height);
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
padding: .75rem 1rem;
|
||||
position: sticky;
|
||||
background: #F5F5F8;
|
||||
box-shadow: var(--td-shadow-1);
|
||||
flex-direction: column;
|
||||
|
||||
.search-input {
|
||||
--td-input-vertical-padding: 8px;
|
||||
|
||||
border: 1px solid rgba(148, 163, 184, .28);
|
||||
z-index: 1;
|
||||
background: #f8fbff;
|
||||
border-radius: 1rem;
|
||||
}
|
||||
|
||||
.filter-menu {
|
||||
--td-spacer-1: 6px;
|
||||
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
margin-top: -1rem;
|
||||
padding-top: .8rem;
|
||||
background: #fff;
|
||||
border: 1px solid rgba(148, 163, 184, .18);
|
||||
border-radius: 0 0 1rem 1rem;
|
||||
|
||||
&:after {
|
||||
display: none;
|
||||
}
|
||||
|
||||
:deep(.t-dropdown-menu__item) {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
:deep(.t-dropdown-menu__title) {
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.summary {
|
||||
color: #52607a;
|
||||
font-size: .8125rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.record-list {
|
||||
|
||||
.item {
|
||||
|
||||
&:last-child {
|
||||
|
||||
:deep(.t-cell:after) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.photo {
|
||||
width: 3.5rem;
|
||||
height: 3.5rem;
|
||||
border-radius: .875rem;
|
||||
|
||||
&.fallback {
|
||||
color: #2563eb;
|
||||
display: flex;
|
||||
font-size: 1.25rem;
|
||||
background: #dbeafe;
|
||||
align-items: center;
|
||||
font-weight: 600;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
.delete {
|
||||
height: 100%;
|
||||
padding: 0 1rem;
|
||||
border-radius: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.empty-block,
|
||||
.load-state {
|
||||
color: #52607a;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
.export {
|
||||
left: 1rem;
|
||||
right: 1rem;
|
||||
bottom: calc(.75rem + env(safe-area-inset-bottom, 0));
|
||||
z-index: 20;
|
||||
position: fixed;
|
||||
box-shadow: 0 -.375rem 1.25rem rgba(15, 23, 42, .08);
|
||||
backdrop-filter: blur(.75rem);
|
||||
}
|
||||
|
||||
:global(html.theme-dark) {
|
||||
.registration-record-list {
|
||||
|
||||
.search-bar {
|
||||
background: #10161f;
|
||||
|
||||
.search-input {
|
||||
background: rgba(15, 23, 42, .72);
|
||||
border-color: rgba(148, 163, 184, .16);
|
||||
}
|
||||
|
||||
.filter-menu {
|
||||
background: #172033;
|
||||
border-color: rgba(148, 163, 184, .16);
|
||||
}
|
||||
}
|
||||
|
||||
.empty-block,
|
||||
.load-state,
|
||||
.summary {
|
||||
color: #94a3b8;
|
||||
}
|
||||
}
|
||||
|
||||
.bottom-bar {
|
||||
background: rgba(15, 23, 42, .88);
|
||||
box-shadow: 0 -.375rem 1.25rem rgba(2, 6, 23, .32);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,194 @@
|
||||
<template>
|
||||
<div v-role="permissionManagementRoleAccess" v-can="pageAccess" class="role-edit">
|
||||
<t-form class="form" label-align="left">
|
||||
<t-form-item label="名称">
|
||||
<t-input v-model.trim="formModel.name" placeholder="角色名称" borderless />
|
||||
</t-form-item>
|
||||
<t-form-item label="代码">
|
||||
<t-input v-model.trim="formModel.code" placeholder="如 front-desk" borderless />
|
||||
</t-form-item>
|
||||
<t-form-item label="模块">
|
||||
<t-input v-model.trim="formModel.moduleCode" placeholder="模块代码" borderless />
|
||||
</t-form-item>
|
||||
<t-form-item label="说明">
|
||||
<t-textarea v-model.trim="formModel.description" class="textarea" placeholder="角色说明" autosize borderless />
|
||||
</t-form-item>
|
||||
</t-form>
|
||||
<section class="children">
|
||||
<div class="title">
|
||||
<span>子角色</span>
|
||||
<span class="hint">父角色会继承子角色权限</span>
|
||||
</div>
|
||||
<t-checkbox-group class="group" v-model="formModel.childRoleIdList">
|
||||
<t-checkbox
|
||||
class="item bg-transparent"
|
||||
v-for="item in childOptions"
|
||||
:key="item.id"
|
||||
:value="item.id"
|
||||
:label="item.name"
|
||||
icon="line"
|
||||
borderless
|
||||
default-checked
|
||||
/>
|
||||
</t-checkbox-group>
|
||||
</section>
|
||||
<t-button v-can="savePermissionCode" class="save" theme="primary" @click="saveRole">保存</t-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Toast } from "tdesign-mobile-vue";
|
||||
import { gaoModuleCode, usePermissionSettingsStore } from "@/store/permissionSettingsStore";
|
||||
import { corePermissionCodes, permissionManagementRoleAccess } from "@/utils/gaoPermission";
|
||||
|
||||
defineOptions({
|
||||
name: "RoleEdit"
|
||||
});
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const permissionStore = usePermissionSettingsStore();
|
||||
const routeId = computed(() => String(route.params.id || ""));
|
||||
const isCreateMode = computed(() => !routeId.value);
|
||||
const savePermissionCode = computed(() => isCreateMode.value ? corePermissionCodes.role.create : corePermissionCodes.role.update);
|
||||
const pageAccess = computed(() => ({
|
||||
value: [corePermissionCodes.role.read, corePermissionCodes.permission.read, savePermissionCode.value],
|
||||
mode: "and" as const
|
||||
}));
|
||||
const formModel = reactive({
|
||||
id: "",
|
||||
moduleCode: gaoModuleCode,
|
||||
code: "",
|
||||
name: "",
|
||||
description: "",
|
||||
childRoleIdList: [] as string[]
|
||||
});
|
||||
const childOptions = computed(() => permissionStore.roleList.filter(item => item.id !== formModel.id && !hasChildRole(item.id, formModel.id)));
|
||||
|
||||
function hasChildRole(roleId: string, targetRoleId: string, visitedRoleIds = new Set<string>()): boolean {
|
||||
if (visitedRoleIds.has(roleId)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const current = permissionStore.getRoleById(roleId);
|
||||
if (!current) {
|
||||
return false;
|
||||
}
|
||||
|
||||
visitedRoleIds.add(roleId);
|
||||
return current.childRoleIdList.some(childRoleId => childRoleId === targetRoleId || hasChildRole(childRoleId, targetRoleId, visitedRoleIds));
|
||||
}
|
||||
|
||||
async function fillForm(): Promise<void> {
|
||||
await permissionStore.loadAll();
|
||||
const current = await permissionStore.loadRoleDetail(routeId.value);
|
||||
Object.assign(formModel, {
|
||||
id: current.id,
|
||||
moduleCode: current.moduleCode || gaoModuleCode,
|
||||
code: current.code,
|
||||
name: current.name,
|
||||
description: current.description,
|
||||
childRoleIdList: [...current.childRoleIdList]
|
||||
});
|
||||
}
|
||||
|
||||
async function saveRole(): Promise<void> {
|
||||
const role = await permissionStore.upsertRole({
|
||||
id: formModel.id,
|
||||
moduleCode: gaoModuleCode,
|
||||
code: formModel.code,
|
||||
name: formModel.name,
|
||||
description: formModel.description,
|
||||
permissionIdList: permissionStore.getRoleById(formModel.id)?.permissionIdList || [],
|
||||
childRoleIdList: [...formModel.childRoleIdList]
|
||||
}, isCreateMode.value);
|
||||
Toast({
|
||||
theme: "success",
|
||||
direction: "row",
|
||||
message: "角色已保存"
|
||||
});
|
||||
await router.push(isCreateMode.value ? "/management/permissions/roles" : `/management/permissions/roles/${role?.id || formModel.id}`);
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await fillForm();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.role-edit {
|
||||
padding: calc(var(--app-nav-height) + 1rem) 0 5rem 0;
|
||||
|
||||
.form {
|
||||
border-radius: .75rem;
|
||||
}
|
||||
|
||||
.textarea {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.children {
|
||||
gap: .75rem;
|
||||
display: flex;
|
||||
padding: 1rem;
|
||||
flex-direction: column;
|
||||
|
||||
.title {
|
||||
gap: .25rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
span:first-child {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.hint {
|
||||
color: #52607a;
|
||||
font-size: .8125rem;
|
||||
}
|
||||
}
|
||||
|
||||
.group {
|
||||
--td-checkbox-vertical-padding: 10px;
|
||||
|
||||
background: #FFF;
|
||||
border-radius: 1rem;
|
||||
|
||||
.item:not(:last-child)::after {
|
||||
content: ' ';
|
||||
left: 16px;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
position: absolute;
|
||||
box-sizing: border-box;
|
||||
border-bottom: 1px solid var(--td-component-stroke, var(--td-gray-color-3, #e7e7e7));
|
||||
pointer-events: none;
|
||||
-webkit-box-sizing: border-box;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.save {
|
||||
left: 1rem;
|
||||
right: 1rem;
|
||||
bottom: calc(.75rem + env(safe-area-inset-bottom, 0));
|
||||
position: fixed;
|
||||
}
|
||||
}
|
||||
|
||||
:global(html.theme-dark) {
|
||||
.role-edit {
|
||||
.children {
|
||||
.title .hint {
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.group .card {
|
||||
color: #e2e8f0;
|
||||
background: rgba(15, 23, 42, .72);
|
||||
border-color: rgba(148, 163, 184, .16);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,89 @@
|
||||
<template>
|
||||
<div v-role="permissionManagementRoleAccess" v-can="{ value: [corePermissionCodes.role.read, corePermissionCodes.permission.read], mode: 'and' }" class="role-list">
|
||||
<t-cell-group v-if="sortedRoles.length" theme="card">
|
||||
<t-swipe-cell
|
||||
v-for="item in sortedRoles"
|
||||
:key="item.id"
|
||||
class="item"
|
||||
>
|
||||
<t-cell
|
||||
:title="item.name"
|
||||
:note="item.code"
|
||||
arrow
|
||||
hover
|
||||
@click="goEdit(item.id)"
|
||||
/>
|
||||
<template #right>
|
||||
<t-button v-can="corePermissionCodes.role.delete" class="delete" theme="danger" @click="removeRole(item.id)">删除</t-button>
|
||||
</template>
|
||||
</t-swipe-cell>
|
||||
</t-cell-group>
|
||||
<t-empty v-else-if="!permissionStore.loading" description="暂无角色" />
|
||||
<t-button v-can="corePermissionCodes.role.create" class="add" theme="primary" type="button" @click="goCreate">
|
||||
<template #icon>
|
||||
<t-icon name="add" />
|
||||
</template>
|
||||
新增角色
|
||||
</t-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Role } from "@/store/permissionSettingsStore";
|
||||
import { usePermissionSettingsStore } from "@/store/permissionSettingsStore";
|
||||
import { corePermissionCodes, permissionManagementRoleAccess } from "@/utils/gaoPermission";
|
||||
|
||||
defineOptions({
|
||||
name: "RoleList"
|
||||
});
|
||||
|
||||
const router = useRouter();
|
||||
const permissionStore = usePermissionSettingsStore();
|
||||
const sortedRoles = computed<Role[]>(() => [...permissionStore.roleList].sort((left, right) => (left.name || "").localeCompare(right.name || "")));
|
||||
|
||||
async function goCreate(): Promise<void> {
|
||||
await router.push("/management/permissions/roles/create");
|
||||
}
|
||||
|
||||
async function goEdit(id: string): Promise<void> {
|
||||
await router.push(`/management/permissions/roles/${id}`);
|
||||
}
|
||||
|
||||
async function removeRole(id: string): Promise<void> {
|
||||
await permissionStore.removeRole(id);
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await permissionStore.loadAll();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.role-list {
|
||||
padding: calc(var(--app-nav-height) + 1rem) 0 6rem 0;
|
||||
|
||||
.item {
|
||||
|
||||
&:last-child {
|
||||
|
||||
:deep(.t-cell:after) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.delete {
|
||||
height: 100%;
|
||||
padding: 0 1rem;
|
||||
border-radius: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.add {
|
||||
left: 1rem;
|
||||
right: 1rem;
|
||||
bottom: calc(.75rem + env(safe-area-inset-bottom, 0));
|
||||
position: fixed;
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
||||
@@ -0,0 +1,290 @@
|
||||
<template>
|
||||
<div v-role="permissionManagementRoleAccess" v-can="{ value: [corePermissionCodes.role.read, corePermissionCodes.permission.read], mode: 'and' }" class="role-permission-assign">
|
||||
<div class="header bg-white">
|
||||
<t-dropdown-menu class="role-selector">
|
||||
<t-dropdown-item
|
||||
:label="activeRoleLabel"
|
||||
:options="roleOptions"
|
||||
:value="activeRoleId"
|
||||
borderless
|
||||
@change="handleRoleChange"
|
||||
/>
|
||||
</t-dropdown-menu>
|
||||
<div class="inherit">
|
||||
<span class="label">显示子角色权限</span>
|
||||
<t-switch class="switch" v-model="showChildRolePermissions" />
|
||||
</div>
|
||||
</div>
|
||||
<t-cell-group v-if="activeRole && permissionStore.permissionList.length" theme="card">
|
||||
<t-cell
|
||||
v-for="item in permissionStore.permissionList"
|
||||
:key="item.id"
|
||||
:title="item.name"
|
||||
:class="{ inherited: isPermissionInherited(item.id) }"
|
||||
>
|
||||
<template #description>
|
||||
<span class="code" v-text="item.code"></span>
|
||||
</template>
|
||||
<template #right-icon>
|
||||
<t-switch
|
||||
:disabled="!canUpdateRole || isPermissionInherited(item.id)"
|
||||
:model-value="isPermissionEnabled(item.id)"
|
||||
@change="value => setPermissionChecked(item.id, value === true)"
|
||||
/>
|
||||
</template>
|
||||
</t-cell>
|
||||
</t-cell-group>
|
||||
<t-empty v-else-if="!permissionStore.loading" description="暂无可授权权限" />
|
||||
<t-button
|
||||
v-can="corePermissionCodes.role.update"
|
||||
class="save"
|
||||
theme="primary"
|
||||
@click="savePermissionList"
|
||||
>
|
||||
保存授权
|
||||
</t-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { DropdownOption, DropdownValue } from "tdesign-mobile-vue";
|
||||
import { Toast } from "tdesign-mobile-vue";
|
||||
import { useUserStore } from "timi-web";
|
||||
import { usePermissionSettingsStore } from "@/store/permissionSettingsStore";
|
||||
import { corePermissionCodes, permissionManagementRoleAccess } from "@/utils/gaoPermission";
|
||||
|
||||
defineOptions({
|
||||
name: "RolePermissionAssign"
|
||||
});
|
||||
|
||||
const permissionStore = usePermissionSettingsStore();
|
||||
const userStore = useUserStore();
|
||||
const route = useRoute();
|
||||
const activeRoleId = ref(String(route.query.roleId || ""));
|
||||
const checkedPermissionIds = ref<string[]>([]);
|
||||
const showChildRolePermissions = ref(false);
|
||||
let permissionSyncSeq = 0;
|
||||
const activeRole = computed(() => permissionStore.getRoleById(activeRoleId.value));
|
||||
const activeRoleLabel = computed(() => activeRole.value ? `当前角色:${activeRole.value.name}` : "选择角色");
|
||||
const canUpdateRole = computed(() => userStore.hasPermissions(corePermissionCodes.role.update));
|
||||
const roleOptions = computed<DropdownOption[]>(() => permissionStore.roleList
|
||||
.filter(item => !!item.id)
|
||||
.map(item => ({
|
||||
label: item.name || item.code,
|
||||
value: item.id,
|
||||
disabled: false
|
||||
})));
|
||||
const checkedPermissionIdSet = computed(() => new Set(checkedPermissionIds.value));
|
||||
const childRolePermissionIdSet = computed(() => {
|
||||
if (!activeRole.value) {
|
||||
return new Set<string>();
|
||||
}
|
||||
return collectChildRolePermissionIds(activeRole.value.childRoleIdList, new Set<string>());
|
||||
});
|
||||
|
||||
async function handleRoleChange(value: DropdownValue): Promise<void> {
|
||||
activeRoleId.value = Array.isArray(value) ? String(value[0] || "") : String(value);
|
||||
await loadActiveRolePermissions();
|
||||
}
|
||||
|
||||
async function loadActiveRolePermissions(): Promise<void> {
|
||||
const roleId = activeRoleId.value;
|
||||
if (!roleId) {
|
||||
checkedPermissionIds.value = [];
|
||||
return;
|
||||
}
|
||||
|
||||
const currentSeq = permissionSyncSeq + 1;
|
||||
permissionSyncSeq = currentSeq;
|
||||
const role = await permissionStore.loadRoleDetail(roleId);
|
||||
if (currentSeq !== permissionSyncSeq || activeRoleId.value !== roleId) {
|
||||
return;
|
||||
}
|
||||
|
||||
checkedPermissionIds.value = [...role.permissionIdList];
|
||||
}
|
||||
|
||||
async function savePermissionList(): Promise<void> {
|
||||
await permissionStore.setRolePermissionList(activeRoleId.value, checkedPermissionIds.value);
|
||||
Toast({
|
||||
theme: "success",
|
||||
direction: "row",
|
||||
message: "授权已保存"
|
||||
});
|
||||
}
|
||||
|
||||
function isPermissionChecked(permissionId: string): boolean {
|
||||
return checkedPermissionIdSet.value.has(permissionId);
|
||||
}
|
||||
|
||||
function isPermissionInherited(permissionId: string): boolean {
|
||||
return showChildRolePermissions.value && !isPermissionChecked(permissionId) && childRolePermissionIdSet.value.has(permissionId);
|
||||
}
|
||||
|
||||
function isPermissionEnabled(permissionId: string): boolean {
|
||||
return isPermissionChecked(permissionId) || isPermissionInherited(permissionId);
|
||||
}
|
||||
|
||||
function setPermissionChecked(permissionId: string, value: boolean): void {
|
||||
if (!canUpdateRole.value || isPermissionInherited(permissionId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (value && !checkedPermissionIds.value.includes(permissionId)) {
|
||||
checkedPermissionIds.value.push(permissionId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!value) {
|
||||
checkedPermissionIds.value = checkedPermissionIds.value.filter(item => item !== permissionId);
|
||||
}
|
||||
}
|
||||
|
||||
function collectChildRolePermissionIds(roleIdList: string[], visitedRoleIdSet: Set<string>): Set<string> {
|
||||
const permissionIdSet = new Set<string>();
|
||||
for (const roleId of roleIdList) {
|
||||
if (visitedRoleIdSet.has(roleId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
visitedRoleIdSet.add(roleId);
|
||||
const role = permissionStore.getRoleById(roleId);
|
||||
if (!role) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const permissionId of role.permissionIdList) {
|
||||
permissionIdSet.add(permissionId);
|
||||
}
|
||||
|
||||
for (const permissionId of collectChildRolePermissionIds(role.childRoleIdList, visitedRoleIdSet)) {
|
||||
permissionIdSet.add(permissionId);
|
||||
}
|
||||
}
|
||||
return permissionIdSet;
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await permissionStore.loadAll();
|
||||
if (!activeRoleId.value) {
|
||||
activeRoleId.value = permissionStore.roleList[0]?.id || "";
|
||||
}
|
||||
await loadActiveRolePermissions();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.role-permission-assign {
|
||||
gap: 1rem;
|
||||
display: flex;
|
||||
padding: calc(var(--app-nav-height) + 4.5rem) 0 6rem 0;
|
||||
flex-direction: column;
|
||||
|
||||
.header {
|
||||
top: var(--app-nav-height);
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 20;
|
||||
display: flex;
|
||||
position: fixed;
|
||||
box-shadow: var(--td-shadow-1);
|
||||
align-items: center;
|
||||
justify-content: space-around;
|
||||
|
||||
.role-selector::after {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.inherit {
|
||||
gap: .75rem;
|
||||
display: flex;
|
||||
padding: .75rem 1rem;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
|
||||
.label {
|
||||
color: #334155;
|
||||
font-size: .875rem;
|
||||
line-height: 1.25rem;
|
||||
}
|
||||
|
||||
.switch {
|
||||
--td-switch-checked-color: #14b8a6;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.t-cell__title) {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
:deep(.t-cell__title-text) {
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
:deep(.t-cell__description) {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
:deep(.t-cell__right) {
|
||||
flex: 0 0 auto;
|
||||
margin-left: .75rem;
|
||||
}
|
||||
|
||||
.code {
|
||||
width: 100%;
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.inherited {
|
||||
:deep(.t-cell__title) {
|
||||
color: #0f766e;
|
||||
}
|
||||
|
||||
:deep(.t-switch--checked.t-switch--disabled) {
|
||||
background: #14b8a6;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.save {
|
||||
left: 1rem;
|
||||
right: 1rem;
|
||||
bottom: calc(.75rem + env(safe-area-inset-bottom, 0));
|
||||
position: fixed;
|
||||
}
|
||||
}
|
||||
|
||||
:global(html.theme-dark) {
|
||||
.role-permission-assign {
|
||||
.role-menu {
|
||||
background: #0f172a;
|
||||
|
||||
.inherit {
|
||||
.label {
|
||||
color: #cbd5e1;
|
||||
}
|
||||
|
||||
.switch {
|
||||
--td-switch-checked-color: #0f766e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.inherited {
|
||||
:deep(.t-cell__title) {
|
||||
color: #5eead4;
|
||||
}
|
||||
|
||||
:deep(.t-switch--checked.t-switch--disabled) {
|
||||
background: #0f766e;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,240 @@
|
||||
<template>
|
||||
<div v-can="gaoPermissionCodes.user.read" class="user-edit">
|
||||
<t-cell-group v-if="currentUser" theme="card">
|
||||
<t-cell
|
||||
:title="currentUser.name"
|
||||
:description="currentUser.role"
|
||||
>
|
||||
<template #leftIcon>
|
||||
<t-image
|
||||
class="avatar"
|
||||
:src="currentUser.avatar || buildFallbackAvatar(currentUser.name)"
|
||||
:alt="currentUser.name"
|
||||
fit="cover"
|
||||
/>
|
||||
</template>
|
||||
</t-cell>
|
||||
</t-cell-group>
|
||||
|
||||
<t-cell-group v-if="currentUser" theme="card">
|
||||
<t-cell title="手机号" :description="currentUser.mobile || '未填写'" />
|
||||
<t-cell title="上次登录时间" :description="currentUser.lastLoginAt || '暂无记录'" />
|
||||
</t-cell-group>
|
||||
|
||||
<t-cell-group v-if="currentUser" theme="card">
|
||||
<t-cell
|
||||
v-can="gaoPermissionCodes.user.update"
|
||||
title="配置角色"
|
||||
note="为用户授权业务角色"
|
||||
arrow
|
||||
hover
|
||||
@click="goRoleAssign"
|
||||
>
|
||||
<template #leftIcon>
|
||||
<t-icon name="usergroup" size="1.75rem" />
|
||||
</template>
|
||||
</t-cell>
|
||||
</t-cell-group>
|
||||
|
||||
<section v-if="currentUser" class="registration-actions bg-white">
|
||||
<header>
|
||||
<h4 class="title">可登记事件</h4>
|
||||
</header>
|
||||
<div class="actions">
|
||||
<t-tag
|
||||
v-for="item in scopeList"
|
||||
:key="item"
|
||||
variant="light-outline"
|
||||
theme="success"
|
||||
:content="item"
|
||||
/>
|
||||
<div v-if="!scopeList.length" class="empty-card">未配置</div>
|
||||
</div>
|
||||
</section>
|
||||
<t-button v-can="gaoPermissionCodes.user.update" class="edit" theme="primary" @click="goEdit">编辑用户</t-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Toast } from "tdesign-mobile-vue";
|
||||
import { UserAPI, type User } from "timi-web";
|
||||
import RegistrationEventAPI from "@/api/RegistrationEventAPI";
|
||||
import GaoUserAPI from "@/api/GaoUserAPI";
|
||||
import type { GaoUser } from "@/types/GaoUser";
|
||||
import type { RegistrationEvent } from "@/types/RegistrationEvent";
|
||||
import { usePermissionSettingsStore } from "@/store/permissionSettingsStore";
|
||||
import { buildEffectiveRoleCodeSet, getGaoRoleName } from "@/utils/gaoRole";
|
||||
import { gaoPermissionCodes } from "@/utils/gaoPermission";
|
||||
|
||||
defineOptions({
|
||||
name: "UserDetail"
|
||||
});
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const permissionStore = usePermissionSettingsStore();
|
||||
const gaoUser = ref<GaoUser>();
|
||||
const registrationEventList = ref<RegistrationEvent[]>([]);
|
||||
|
||||
const currentUser = computed(() => {
|
||||
const item = gaoUser.value;
|
||||
if (!item) {
|
||||
return null;
|
||||
}
|
||||
const user = item.user;
|
||||
return {
|
||||
id: item.id || "",
|
||||
name: user?.nick || user?.name || "未命名用户",
|
||||
role: getGaoRoleName(item.roleList?.[0], permissionStore.roleList),
|
||||
avatar: buildUserAvatar(user),
|
||||
mobile: user?.phoneNo || "",
|
||||
lastLoginAt: user?.lastLoginAt ? new Date(user.lastLoginAt).toLocaleString() : ""
|
||||
};
|
||||
});
|
||||
const scopeList = computed(() => {
|
||||
const item = gaoUser.value;
|
||||
if (!item) {
|
||||
return [];
|
||||
}
|
||||
return registrationEventList.value
|
||||
.filter(action => canRegistrationEvent(action, item))
|
||||
.map(item => item.name);
|
||||
});
|
||||
|
||||
async function goEdit(): Promise<void> {
|
||||
if (!currentUser.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
await router.push(`/management/users/${currentUser.value.id}/edit`);
|
||||
}
|
||||
|
||||
async function goRoleAssign(): Promise<void> {
|
||||
if (!currentUser.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
await router.push({
|
||||
path: `/management/users/${currentUser.value.id}/roles`
|
||||
});
|
||||
}
|
||||
|
||||
function buildFallbackAvatar(name: string): string {
|
||||
const text = encodeURIComponent((name.trim() || "用户").slice(0, 2));
|
||||
return [
|
||||
"data:image/svg+xml;charset=UTF-8,",
|
||||
"%3Csvg xmlns='http://www.w3.org/2000/svg' width='120' height='120' viewBox='0 0 120 120'%3E",
|
||||
"%3Crect width='120' height='120' rx='24' fill='%232563eb'/%3E",
|
||||
"%3Ctext x='50%25' y='54%25' fill='white' font-size='36' font-family='Arial,sans-serif' text-anchor='middle' dominant-baseline='middle'%3E",
|
||||
text,
|
||||
"%3C/text%3E%3C/svg%3E"
|
||||
].join("");
|
||||
}
|
||||
|
||||
function buildUserAvatar(user?: Partial<User>): string {
|
||||
const name = user?.nick || user?.name || "用户";
|
||||
return isUser(user) ? UserAPI.getAvatarURL(user) || buildFallbackAvatar(name) : buildFallbackAvatar(name);
|
||||
}
|
||||
|
||||
function isUser(user?: Partial<User>): user is User {
|
||||
return typeof user?.name === "string";
|
||||
}
|
||||
|
||||
function canRegistrationEvent(action: RegistrationEvent, item: GaoUser): boolean {
|
||||
const userRoleCodeSet = buildEffectiveRoleCodeSet(item, permissionStore.roleList);
|
||||
const roleCodeList = action.roleCodeList || [];
|
||||
if (!roleCodeList.length) {
|
||||
return true;
|
||||
}
|
||||
return roleCodeList.some(code => userRoleCodeSet.has(code));
|
||||
}
|
||||
|
||||
async function loadData(): Promise<void> {
|
||||
const routeId = String(route.params.id || "");
|
||||
const [detail, actions] = await Promise.all([
|
||||
GaoUserAPI.detail(routeId),
|
||||
RegistrationEventAPI.enabledList(),
|
||||
permissionStore.loadAll()
|
||||
]);
|
||||
gaoUser.value = detail;
|
||||
registrationEventList.value = actions || [];
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
await loadData();
|
||||
} catch {
|
||||
Toast({
|
||||
theme: "error",
|
||||
direction: "row",
|
||||
message: "用户详情加载失败"
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.user-edit {
|
||||
gap: 1rem;
|
||||
display: flex;
|
||||
padding: calc(var(--app-nav-height) + 1rem) 0 4rem 0;
|
||||
flex-direction: column;
|
||||
|
||||
.avatar {
|
||||
width: 5rem;
|
||||
height: 5rem;
|
||||
border-radius: .875rem;
|
||||
}
|
||||
|
||||
.registration-actions {
|
||||
gap: .5rem;
|
||||
margin: 0 1rem;
|
||||
padding: 1rem;
|
||||
display: flex;
|
||||
border-radius: 1rem;
|
||||
flex-direction: column;
|
||||
|
||||
.title {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.actions {
|
||||
gap: .5rem;
|
||||
display: flex;
|
||||
|
||||
.empty-card {
|
||||
color: #52607a;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.edit {
|
||||
left: 1rem;
|
||||
right: 1rem;
|
||||
bottom: calc(.75rem + env(safe-area-inset-bottom, 0));
|
||||
position: fixed;
|
||||
}
|
||||
}
|
||||
|
||||
:global(html.theme-dark) {
|
||||
.user-edit {
|
||||
.panel,
|
||||
.bottom-bar {
|
||||
background: rgba(15, 23, 42, .92);
|
||||
box-shadow: 0 .75rem 2rem rgba(2, 6, 23, .32);
|
||||
}
|
||||
|
||||
p,
|
||||
.info-list li span,
|
||||
.empty-card {
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.action {
|
||||
color: #e2e8f0;
|
||||
background: rgba(15, 23, 42, .72);
|
||||
border-color: rgba(148, 163, 184, .16);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,147 @@
|
||||
<template>
|
||||
<div v-can="savePermissionCode" class="user-edit">
|
||||
<t-form class="form" label-align="left">
|
||||
<t-form-item label="姓名">
|
||||
<t-input v-model.trim="formModel.name" placeholder="姓名" borderless />
|
||||
</t-form-item>
|
||||
<t-form-item v-if="isCreateMode" label="手机号">
|
||||
<t-input v-model.trim="formModel.mobile" placeholder="手机号" borderless />
|
||||
</t-form-item>
|
||||
<t-form-item v-if="isCreateMode" label="密码">
|
||||
<t-input v-model.trim="formModel.password" type="password" placeholder="密码" borderless />
|
||||
</t-form-item>
|
||||
</t-form>
|
||||
<t-button class="save" theme="primary" :loading="isSaving" @click="saveUser">保存</t-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Toast } from "tdesign-mobile-vue";
|
||||
import GaoUserAPI from "@/api/GaoUserAPI";
|
||||
import type { GaoUser } from "@/types/GaoUser";
|
||||
import { gaoPermissionCodes } from "@/utils/gaoPermission";
|
||||
|
||||
defineOptions({
|
||||
name: "UserEdit"
|
||||
});
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const currentGaoUser = ref<GaoUser>();
|
||||
const isSaving = ref(false);
|
||||
const formModel = reactive({
|
||||
id: "",
|
||||
userId: "",
|
||||
name: "",
|
||||
mobile: "",
|
||||
password: ""
|
||||
});
|
||||
const isCreateMode = computed(() => !String(route.params.id || ""));
|
||||
const savePermissionCode = computed(() => isCreateMode.value ? gaoPermissionCodes.user.create : gaoPermissionCodes.user.update);
|
||||
|
||||
async function fillForm(): Promise<void> {
|
||||
const routeId = String(route.params.id || "");
|
||||
if (routeId) {
|
||||
const current = await GaoUserAPI.detail(routeId);
|
||||
currentGaoUser.value = current;
|
||||
Object.assign(formModel, {
|
||||
id: current.id,
|
||||
userId: current.userId || "",
|
||||
name: current.user?.nick || current.user?.name || "",
|
||||
mobile: current.user?.phoneNo || "",
|
||||
password: ""
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
Object.assign(formModel, {
|
||||
id: "",
|
||||
userId: "",
|
||||
name: "",
|
||||
mobile: "",
|
||||
password: ""
|
||||
});
|
||||
}
|
||||
|
||||
async function saveUser(): Promise<void> {
|
||||
if (!formModel.name) {
|
||||
Toast({
|
||||
theme: "warning",
|
||||
direction: "row",
|
||||
message: "请填写姓名"
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (isCreateMode.value && !formModel.password) {
|
||||
Toast({
|
||||
theme: "warning",
|
||||
direction: "row",
|
||||
message: "请填写密码"
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
isSaving.value = true;
|
||||
try {
|
||||
const payload: GaoUser = {
|
||||
id: formModel.id || undefined,
|
||||
userId: formModel.userId || undefined,
|
||||
enabled: currentGaoUser.value?.enabled ?? true,
|
||||
remark: currentGaoUser.value?.remark || "",
|
||||
user: {
|
||||
id: formModel.userId || undefined,
|
||||
name: formModel.name,
|
||||
nick: formModel.name,
|
||||
phoneNo: formModel.mobile || undefined,
|
||||
password: isCreateMode.value ? formModel.password : undefined
|
||||
}
|
||||
};
|
||||
const saved = isCreateMode.value ? await GaoUserAPI.create(payload) : await GaoUserAPI.update(payload);
|
||||
Toast({
|
||||
theme: "success",
|
||||
direction: "row",
|
||||
message: "用户已保存"
|
||||
});
|
||||
await router.push(`/management/users/${saved.id}`);
|
||||
} finally {
|
||||
isSaving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
await fillForm();
|
||||
} catch {
|
||||
Toast({
|
||||
theme: "error",
|
||||
direction: "row",
|
||||
message: "用户数据加载失败"
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.user-edit {
|
||||
padding: calc(var(--app-nav-height) + 1rem) 0 4rem 0;
|
||||
|
||||
.form {
|
||||
border-radius: 1rem;
|
||||
}
|
||||
|
||||
.save {
|
||||
left: 1rem;
|
||||
right: 1rem;
|
||||
bottom: calc(.75rem + env(safe-area-inset-bottom, 0));
|
||||
position: fixed;
|
||||
}
|
||||
}
|
||||
|
||||
:global(html.theme-dark) {
|
||||
.user-edit {
|
||||
:deep(.form-item .t-form__label) {
|
||||
color: #94a3b8;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,195 @@
|
||||
<template>
|
||||
<div v-can="gaoPermissionCodes.user.read" class="user-list">
|
||||
<div v-for="item in userList" :key="item.id" class="item" @click="goDetail(item)">
|
||||
<t-image :src="buildUserAvatar(item)" :alt="getUserName(item)" fit="cover" class="avatar" />
|
||||
<div class="content">
|
||||
<div class="header">
|
||||
<span class="name" v-text="getUserName(item)" />
|
||||
<t-tag class="meta" size="small" theme="success" variant="outline" :content="getRoleName(item)" />
|
||||
</div>
|
||||
<span class="events gray" v-text="`可登记事件:${getActionScope(item)}`" />
|
||||
</div>
|
||||
</div>
|
||||
<t-button v-can="gaoPermissionCodes.user.create" class="add" theme="primary" type="button" @click="goCreate">
|
||||
<template #icon>
|
||||
<t-icon name="add" />
|
||||
</template>
|
||||
新增
|
||||
</t-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Toast } from "tdesign-mobile-vue";
|
||||
import { UserAPI, type User } from "timi-web";
|
||||
import RegistrationEventAPI from "@/api/RegistrationEventAPI";
|
||||
import GaoUserAPI from "@/api/GaoUserAPI";
|
||||
import type { GaoUser } from "@/types/GaoUser";
|
||||
import type { RegistrationEvent } from "@/types/RegistrationEvent";
|
||||
import { usePermissionSettingsStore } from "@/store/permissionSettingsStore";
|
||||
import { buildEffectiveRoleCodeSet, getGaoRoleName } from "@/utils/gaoRole";
|
||||
import { gaoPermissionCodes } from "@/utils/gaoPermission";
|
||||
|
||||
defineOptions({
|
||||
name: "UserList"
|
||||
});
|
||||
|
||||
const router = useRouter();
|
||||
const permissionStore = usePermissionSettingsStore();
|
||||
const userList = ref<GaoUser[]>([]);
|
||||
const registrationEventList = ref<RegistrationEvent[]>([]);
|
||||
|
||||
function getUserName(item: GaoUser): string {
|
||||
return item.user?.nick || item.user?.name || "未命名用户";
|
||||
}
|
||||
|
||||
function getRoleName(item: GaoUser): string {
|
||||
return getGaoRoleName(item.roleList?.[0], permissionStore.roleList);
|
||||
}
|
||||
|
||||
function getActionScope(item: GaoUser): string {
|
||||
const nameList = registrationEventList.value
|
||||
.filter(action => canRegistrationEvent(action, item))
|
||||
.map(action => action.name);
|
||||
return nameList.join("、") || "未配置";
|
||||
}
|
||||
|
||||
function canRegistrationEvent(action: RegistrationEvent, item: GaoUser): boolean {
|
||||
const userRoleCodeSet = buildEffectiveRoleCodeSet(item, permissionStore.roleList);
|
||||
const roleCodeList = action.roleCodeList || [];
|
||||
if (!roleCodeList.length) {
|
||||
return true;
|
||||
}
|
||||
return roleCodeList.some(code => userRoleCodeSet.has(code));
|
||||
}
|
||||
|
||||
function buildUserAvatar(item: GaoUser): string {
|
||||
return isUser(item.user) ? UserAPI.getAvatarURL(item.user) || buildFallbackAvatar(getUserName(item)) : buildFallbackAvatar(getUserName(item));
|
||||
}
|
||||
|
||||
function isUser(user?: Partial<User>): user is User {
|
||||
return typeof user?.name === "string";
|
||||
}
|
||||
|
||||
function buildFallbackAvatar(name: string): string {
|
||||
const text = encodeURIComponent((name.trim() || "用户").slice(0, 2));
|
||||
return [
|
||||
"data:image/svg+xml;charset=UTF-8,",
|
||||
"%3Csvg xmlns='http://www.w3.org/2000/svg' width='120' height='120' viewBox='0 0 120 120'%3E",
|
||||
"%3Crect width='120' height='120' rx='24' fill='%232563eb'/%3E",
|
||||
"%3Ctext x='50%25' y='54%25' fill='white' font-size='36' font-family='Arial,sans-serif' text-anchor='middle' dominant-baseline='middle'%3E",
|
||||
text,
|
||||
"%3C/text%3E%3C/svg%3E"
|
||||
].join("");
|
||||
}
|
||||
|
||||
async function loadData(): Promise<void> {
|
||||
const [userResult, actions] = await Promise.all([
|
||||
GaoUserAPI.list({
|
||||
index: 0,
|
||||
size: 100
|
||||
}),
|
||||
RegistrationEventAPI.enabledList(),
|
||||
permissionStore.loadAll()
|
||||
]);
|
||||
userList.value = userResult.list || [];
|
||||
registrationEventList.value = actions || [];
|
||||
}
|
||||
|
||||
async function goCreate(): Promise<void> {
|
||||
await router.push("/management/users/create");
|
||||
}
|
||||
|
||||
async function goDetail(item: GaoUser): Promise<void> {
|
||||
if (!item.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
await router.push(`/management/users/${item.id}`);
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
await loadData();
|
||||
} catch {
|
||||
Toast({
|
||||
theme: "error",
|
||||
direction: "row",
|
||||
message: "用户列表加载失败"
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.user-list {
|
||||
padding: calc(var(--app-nav-height) + 1rem) 1rem 6rem 1rem;
|
||||
display: grid;
|
||||
gap: .75rem;
|
||||
|
||||
.item {
|
||||
gap: .5rem;
|
||||
display: flex;
|
||||
padding: .5rem;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
border: 1px solid var(--td-border-level-1-color);
|
||||
background: #f7faff;
|
||||
border-radius: 1rem;
|
||||
|
||||
.avatar {
|
||||
width: 3.5rem;
|
||||
height: 3.5rem;
|
||||
flex: none;
|
||||
border-radius: .5rem;
|
||||
}
|
||||
|
||||
.content {
|
||||
gap: 1rem;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
|
||||
.header {
|
||||
gap: .5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.name {
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.events {
|
||||
font-size: .875rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.add {
|
||||
left: 1rem;
|
||||
right: 1rem;
|
||||
bottom: calc(.75rem + env(safe-area-inset-bottom, 0));
|
||||
position: fixed;
|
||||
}
|
||||
}
|
||||
|
||||
:global(html.theme-dark) {
|
||||
.user-list {
|
||||
.item {
|
||||
background: rgba(30, 41, 59, .9);
|
||||
|
||||
.name {
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
.meta {
|
||||
color: #94a3b8;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,241 @@
|
||||
<template>
|
||||
<div v-can="corePermissionCodes.userRole.read" class="user-role-assign">
|
||||
<div class="user-menu">
|
||||
<t-dropdown-menu>
|
||||
<t-dropdown-item
|
||||
:label="activeUserLabel"
|
||||
:options="userOptions"
|
||||
:value="activeGaoUserId"
|
||||
@change="handleUserChange"
|
||||
/>
|
||||
</t-dropdown-menu>
|
||||
</div>
|
||||
<t-cell-group v-if="activeUser && roleList.length" theme="card">
|
||||
<t-cell
|
||||
v-for="item in roleList"
|
||||
:key="item.id"
|
||||
:title="item.name"
|
||||
:description="item.code"
|
||||
>
|
||||
<template #right-icon>
|
||||
<t-switch
|
||||
v-can="userRoleWriteAccess"
|
||||
:model-value="isRoleChecked(item.id)"
|
||||
@change="value => setRoleChecked(item.id, value === true)"
|
||||
/>
|
||||
</template>
|
||||
</t-cell>
|
||||
</t-cell-group>
|
||||
<t-empty v-else-if="!loading" description="暂无可授权角色" />
|
||||
<t-button
|
||||
v-can="userRoleWriteAccess"
|
||||
class="save"
|
||||
theme="primary"
|
||||
:loading="saving"
|
||||
@click="saveRoleList"
|
||||
>
|
||||
保存授权
|
||||
</t-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { DropdownOption, DropdownValue } from "tdesign-mobile-vue";
|
||||
import { Toast } from "tdesign-mobile-vue";
|
||||
import { OrderType, RoleAPI, type ModuleCode, type Role } from "timi-web";
|
||||
import GaoUserAPI from "@/api/GaoUserAPI";
|
||||
import type { GaoUser } from "@/types/GaoUser";
|
||||
import { corePermissionCodes } from "@/utils/gaoPermission";
|
||||
|
||||
defineOptions({
|
||||
name: "UserRoleAssign"
|
||||
});
|
||||
|
||||
const route = useRoute();
|
||||
const gaoModuleCode: ModuleCode = "GAO";
|
||||
const pageSize = 1000;
|
||||
const userList = ref<GaoUser[]>([]);
|
||||
const roleList = ref<Role[]>([]);
|
||||
const routeGaoUserId = computed(() => String(route.params.id || route.query.gaoUserId || ""));
|
||||
const activeGaoUserId = ref(routeGaoUserId.value);
|
||||
const checkedRoleIds = ref<string[]>([]);
|
||||
const loading = ref(false);
|
||||
const saving = ref(false);
|
||||
let roleSyncSeq = 0;
|
||||
|
||||
const userRoleWriteAccess = {
|
||||
value: [
|
||||
corePermissionCodes.userRole.create,
|
||||
corePermissionCodes.userRole.delete
|
||||
],
|
||||
mode: "and"
|
||||
} as const;
|
||||
const activeUser = computed(() => userList.value.find(item => item.id === activeGaoUserId.value));
|
||||
const activeUserLabel = computed(() => activeUser.value ? `当前用户:${getUserName(activeUser.value)}` : "选择用户");
|
||||
const userOptions = computed<DropdownOption[]>(() => userList.value
|
||||
.filter(item => !!item.id)
|
||||
.map(item => ({
|
||||
label: getUserName(item),
|
||||
value: item.id as string,
|
||||
disabled: false
|
||||
})));
|
||||
|
||||
async function handleUserChange(value: DropdownValue): Promise<void> {
|
||||
activeGaoUserId.value = Array.isArray(value) ? String(value[0] || "") : String(value);
|
||||
await loadActiveUserRoles();
|
||||
}
|
||||
|
||||
async function loadData(): Promise<void> {
|
||||
loading.value = true;
|
||||
try {
|
||||
const [userResult, roles] = await Promise.all([
|
||||
GaoUserAPI.list({
|
||||
index: 0,
|
||||
size: 100
|
||||
}),
|
||||
loadGaoRoleList()
|
||||
]);
|
||||
userList.value = userResult.list || [];
|
||||
roleList.value = roles || [];
|
||||
if (!activeGaoUserId.value) {
|
||||
activeGaoUserId.value = userList.value[0]?.id || "";
|
||||
}
|
||||
await loadActiveUserRoles();
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadActiveUserRoles(): Promise<void> {
|
||||
const gaoUserId = activeGaoUserId.value;
|
||||
const gaoUser = activeUser.value;
|
||||
if (!gaoUserId || !gaoUser?.userId) {
|
||||
checkedRoleIds.value = [];
|
||||
return;
|
||||
}
|
||||
|
||||
const currentSeq = roleSyncSeq + 1;
|
||||
roleSyncSeq = currentSeq;
|
||||
const roles = await RoleAPI.authorizedList(gaoUser.userId, gaoModuleCode);
|
||||
if (currentSeq !== roleSyncSeq || activeGaoUserId.value !== gaoUserId) {
|
||||
return;
|
||||
}
|
||||
|
||||
checkedRoleIds.value = roles.map(getRoleId).filter((item): item is string => !!item);
|
||||
}
|
||||
|
||||
async function saveRoleList(): Promise<void> {
|
||||
const current = activeUser.value;
|
||||
if (!current?.id || !current.userId) {
|
||||
Toast({
|
||||
theme: "warning",
|
||||
direction: "row",
|
||||
message: "请选择用户"
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
saving.value = true;
|
||||
try {
|
||||
await RoleAPI.authorize({
|
||||
userId: current.userId,
|
||||
moduleCode: gaoModuleCode,
|
||||
roleIdList: [...checkedRoleIds.value]
|
||||
});
|
||||
Toast({
|
||||
theme: "success",
|
||||
direction: "row",
|
||||
message: "用户角色已保存"
|
||||
});
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function getUserName(item: GaoUser): string {
|
||||
return item.user?.nick || item.user?.name || item.user?.phoneNo || "未命名用户";
|
||||
}
|
||||
|
||||
async function loadGaoRoleList(): Promise<Role[]> {
|
||||
const result = await RoleAPI.list({
|
||||
index: 0,
|
||||
size: pageSize,
|
||||
equalsExample: {
|
||||
moduleCode: gaoModuleCode
|
||||
},
|
||||
orderMap: {
|
||||
code: OrderType.ASC
|
||||
}
|
||||
});
|
||||
return result.list || [];
|
||||
}
|
||||
|
||||
function isRoleChecked(roleId: string): boolean {
|
||||
return checkedRoleIds.value.includes(roleId);
|
||||
}
|
||||
|
||||
function getRoleId(role: Role | string): string {
|
||||
if (typeof role !== "string") {
|
||||
return role.id;
|
||||
}
|
||||
|
||||
const fullRole = roleList.value.find(item => item.code === role || `${item.moduleCode}:${item.code}` === role);
|
||||
return fullRole?.id || "";
|
||||
}
|
||||
|
||||
function setRoleChecked(roleId: string, value: boolean): void {
|
||||
if (value && !checkedRoleIds.value.includes(roleId)) {
|
||||
checkedRoleIds.value.push(roleId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!value) {
|
||||
checkedRoleIds.value = checkedRoleIds.value.filter(item => item !== roleId);
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
await loadData();
|
||||
} catch {
|
||||
Toast({
|
||||
theme: "error",
|
||||
direction: "row",
|
||||
message: "用户角色加载失败"
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.user-role-assign {
|
||||
gap: 1rem;
|
||||
display: flex;
|
||||
padding: calc(var(--app-nav-height) + 4.5rem) 0 6rem 0;
|
||||
flex-direction: column;
|
||||
|
||||
.user-menu {
|
||||
top: var(--app-nav-height);
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 20;
|
||||
position: fixed;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.save {
|
||||
left: 1rem;
|
||||
right: 1rem;
|
||||
bottom: calc(.75rem + env(safe-area-inset-bottom, 0));
|
||||
position: fixed;
|
||||
}
|
||||
}
|
||||
|
||||
:global(html.theme-dark) {
|
||||
.user-role-assign {
|
||||
.user-menu {
|
||||
background: #0f172a;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user