Compare commits

..

4 Commits

Author SHA1 Message Date
duanshuwen
62a6dfa915 style(线索页面): 格式化LeadsPage.vue模板代码以提升可读性
本次提交仅对模板代码进行缩进和换行格式化,未修改任何业务逻辑,旨在提升代码的可读性与可维护性。
2026-08-27 10:02:06 +08:00
duanshuwen
d1c6bec12d fix: keep lead filters on one row 2026-08-27 10:01:16 +08:00
duanshuwen
afd0973c2d fix: align lead filter action buttons 2026-08-27 09:58:49 +08:00
duanshuwen
ba79ac6441 fix: submit lead status from dialog footer 2026-08-27 09:53:58 +08:00
3 changed files with 109 additions and 34 deletions

View File

@@ -19,4 +19,23 @@ describe("LeadsPage initial loading", () => {
expect(source.match(/class="lead-detail-item"/g)).toHaveLength(6); expect(source.match(/class="lead-detail-item"/g)).toHaveLength(6);
expect(source).toContain("v-permission=\"'admin:leads:update'\""); expect(source).toContain("v-permission=\"'admin:leads:update'\"");
}); });
it("submits a selected status from the dialog footer", async () => {
const source = await readFile(fileURLToPath(new URL("./LeadsPage.vue", import.meta.url)), "utf8");
expect(source).toContain('const pendingStatus = ref<LeadStatus | "">("");');
expect(source).toContain("pendingStatus.value = lead.status;");
expect(source).toContain("async function submitStatus(lead: Lead)");
expect(source).toContain('class="lead-detail-footer"');
expect(source).toContain('@click="submitStatus(selectedLead)"');
expect(source).toContain(">提交状态</el-button>");
expect(source).not.toContain('@update:model-value="changeStatus(selectedLead, $event)"');
});
it("keeps lead filter action buttons the same width", async () => {
const styles = await readFile(fileURLToPath(new URL("../styles.css", import.meta.url)), "utf8");
expect(styles).toContain(".lead-filters > .el-button { width: 60px; }");
expect(styles).toContain(".lead-filters { display: grid; grid-template-columns: 170px 170px minmax(140px, 1fr) 60px 60px; gap: 10px; }");
});
}); });

View File

@@ -8,6 +8,7 @@ import type { Lead, LeadStatus, LeadType } from "@/types";
const leads = ref<Lead[]>([]); const leads = ref<Lead[]>([]);
const detailOpen = ref(false); const detailOpen = ref(false);
const selectedId = ref(""); const selectedId = ref("");
const pendingStatus = ref<LeadStatus | "">("");
const leadType = ref<LeadType | "">(""); const leadType = ref<LeadType | "">("");
const status = ref<LeadStatus | "">(""); const status = ref<LeadStatus | "">("");
const keyword = ref(""); const keyword = ref("");
@@ -46,21 +47,21 @@ function resetFilters() {
function openDetail(lead: Lead) { function openDetail(lead: Lead) {
selectedId.value = lead.id; selectedId.value = lead.id;
pendingStatus.value = lead.status;
detailOpen.value = true; detailOpen.value = true;
} }
async function changeStatus(lead: Lead, nextStatus: LeadStatus) { async function submitStatus(lead: Lead) {
if (updatingId.value || lead.status === nextStatus) return; const nextStatus = pendingStatus.value;
const previous = lead.status; if (updatingId.value || !nextStatus || lead.status === nextStatus) return;
lead.status = nextStatus;
updatingId.value = lead.id; updatingId.value = lead.id;
try { try {
const saved = await updateLeadStatus(lead.id, nextStatus); const saved = await updateLeadStatus(lead.id, nextStatus);
const index = leads.value.findIndex((item) => item.id === saved.id); const index = leads.value.findIndex((item) => item.id === saved.id);
if (index >= 0) leads.value[index] = saved; if (index >= 0) leads.value[index] = saved;
pendingStatus.value = saved.status;
ElMessage.success(`线索已更新为${leadStatusLabel(nextStatus)}`); ElMessage.success(`线索已更新为${leadStatusLabel(nextStatus)}`);
} catch (updateError) { } catch (updateError) {
lead.status = previous;
ElMessage.error(updateError instanceof Error ? updateError.message : "线索状态更新失败"); ElMessage.error(updateError instanceof Error ? updateError.message : "线索状态更新失败");
} finally { } finally {
updatingId.value = ""; updatingId.value = "";
@@ -88,61 +89,112 @@ onMounted(load);
<template> <template>
<div class="page-stack"> <div class="page-stack">
<div class="page-intro"><div><p class="eyebrow">INBOX / LEADS</p><h2>需求线索</h2><p>按类型状态和关键词筛选需求并记录跟进状态</p></div></div> <div class="page-intro">
<div>
<p class="eyebrow">INBOX / LEADS</p>
<h2>需求线索</h2>
<p>按类型状态和关键词筛选需求并记录跟进状态</p>
</div>
</div>
<el-card shadow="never" class="panel-card lead-filter-card"> <el-card shadow="never" class="panel-card lead-filter-card">
<div class="lead-filters"> <div class="lead-filters">
<el-select v-model="leadType" aria-label="线索类型" placeholder="全部类型"><el-option v-for="option in leadTypeOptions" :key="option.value || 'all'" :label="option.label" :value="option.value" /></el-select> <el-select v-model="leadType" aria-label="线索类型" placeholder="全部类型"><el-option v-for="option in leadTypeOptions"
<el-select v-model="status" aria-label="线索状态" placeholder="全部状态" clearable><el-option label="全部状态" value="" /><el-option v-for="option in leadStatusOptions" :key="option.value" :label="option.label" :value="option.value" /></el-select> :key="option.value || 'all'" :label="option.label" :value="option.value" /></el-select>
<el-select v-model="status" aria-label="线索状态" placeholder="全部状态" clearable><el-option label="全部状态"
value="" /><el-option v-for="option in leadStatusOptions" :key="option.value" :label="option.label"
:value="option.value" /></el-select>
<el-input v-model="keyword" clearable placeholder="搜索地点、手机号或备注" aria-label="搜索线索" @keyup.enter="load(1)" /> <el-input v-model="keyword" clearable placeholder="搜索地点、手机号或备注" aria-label="搜索线索" @keyup.enter="load(1)" />
<el-button type="primary" :loading="loading" @click="load(1)">查询</el-button> <el-button type="primary" :loading="loading" @click="load(1)">查询</el-button>
<el-button :disabled="loading" @click="resetFilters">重置</el-button> <el-button :disabled="loading" @click="resetFilters">重置</el-button>
</div> </div>
</el-card> </el-card>
<el-alert v-if="error" :title="error" type="error" show-icon :closable="false"><template #default><el-button link type="primary" @click="load(page)">重试</el-button></template></el-alert> <el-alert v-if="error" :title="error" type="error" show-icon :closable="false"><template #default><el-button link
type="primary" @click="load(page)">重试</el-button></template></el-alert>
<el-card shadow="never" class="panel-card"> <el-card shadow="never" class="panel-card">
<el-skeleton v-if="loading" :rows="8" animated /> <el-skeleton v-if="loading" :rows="8" animated />
<el-empty v-else-if="!leads.length" description="暂无符合条件的需求线索" /> <el-empty v-else-if="!leads.length" description="暂无符合条件的需求线索" />
<template v-else> <template v-else>
<el-table :data="leads" stripe row-key="id"> <el-table :data="leads" stripe row-key="id">
<el-table-column label="需求" min-width="250"><template #default="{ row }"><div class="lead-summary"><strong>{{ row.contactName || "未填写联系人" }}</strong><span>{{ leadTitle(row) }} · {{ row.phone }}</span></div></template></el-table-column> <el-table-column label="需求" min-width="250"><template #default="{ row }">
<div class="lead-summary"><strong>{{ row.contactName || "未填写联系人" }}</strong><span>{{ leadTitle(row) }} ·
{{ row.phone }}</span></div>
</template></el-table-column>
<el-table-column prop="destination" label="目的地" min-width="130" /> <el-table-column prop="destination" label="目的地" min-width="130" />
<el-table-column label="类型" width="100"><template #default="{ row }"><el-tag effect="plain">{{ row.leadType === "vehicle" ? "用车" : "一般" }}</el-tag></template></el-table-column> <el-table-column label="类型" width="100"><template #default="{ row }"><el-tag effect="plain">{{ row.leadType
<el-table-column label="状态" width="110"><template #default="{ row }"><el-tag :type="statusTagType(row.status)" effect="plain">{{ leadStatusLabel(row.status) }}</el-tag></template></el-table-column> === "vehicle" ? "用车" : "一般" }}</el-tag></template></el-table-column>
<el-table-column label="提交时间" min-width="175"><template #default="{ row }">{{ formatDate(row.createdAt) }}</template></el-table-column> <el-table-column label="状态" width="110"><template #default="{ row }"><el-tag :type="statusTagType(row.status)"
<el-table-column label="操作" width="110" fixed="right"><template #default="{ row }"><el-button link type="primary" @click="openDetail(row)">查看详情</el-button></template></el-table-column> effect="plain">{{ leadStatusLabel(row.status) }}</el-tag></template></el-table-column>
<el-table-column label="提交时间" min-width="175"><template #default="{ row }">{{ formatDate(row.createdAt)
}}</template></el-table-column>
<el-table-column label="操作" width="110" fixed="right"><template #default="{ row }"><el-button link
type="primary" @click="openDetail(row)">查看详情</el-button></template></el-table-column>
</el-table> </el-table>
<div class="lead-pagination"><span> {{ total }} </span><el-pagination v-model:current-page="page" v-model:page-size="pageSize" background layout="sizes, prev, pager, next, jumper" :page-sizes="[20, 50, 100]" :total="total" @current-change="load" @size-change="load(1)" /></div> <div class="lead-pagination"><span> {{ total }} </span><el-pagination v-model:current-page="page"
v-model:page-size="pageSize" background layout="sizes, prev, pager, next, jumper"
:page-sizes="[20, 50, 100]" :total="total" @current-change="load" @size-change="load(1)" /></div>
</template> </template>
</el-card> </el-card>
<el-dialog v-model="detailOpen" title="线索详情" width="min(720px, 94vw)" destroy-on-close> <el-dialog v-model="detailOpen" title="线索详情" width="min(720px, 94vw)" class="lead-detail-dialog" destroy-on-close>
<template v-if="selectedLead"> <template v-if="selectedLead">
<div class="lead-detail-toolbar">
<el-tag :type="statusTagType(selectedLead.status)" effect="plain">{{ leadStatusLabel(selectedLead.status) }}</el-tag>
<el-select v-permission="'admin:leads:update'" :model-value="selectedLead.status" :loading="updatingId === selectedLead.id" size="small" @update:model-value="changeStatus(selectedLead, $event)">
<el-option v-for="option in leadStatusOptions" :key="option.value" :label="option.label" :value="option.value" />
</el-select>
</div>
<div class="lead-detail-card"> <div class="lead-detail-card">
<dl class="lead-detail-grid"> <dl class="lead-detail-grid">
<div class="lead-detail-item"><dt>联系人</dt><dd>{{ selectedLead.contactName || "未填写" }}</dd></div> <div class="lead-detail-item">
<div class="lead-detail-item"><dt>联系方式</dt><dd>{{ selectedLead.phone || "未填写" }}</dd></div> <dt>联系</dt>
<div class="lead-detail-item"><dt>需求类型</dt><dd>{{ selectedLead.leadType === "vehicle" ? "用车需求" : "一般需求" }}</dd></div> <dd>{{ selectedLead.contactName || "未填写" }}</dd>
<div class="lead-detail-item"><dt>提交时间</dt><dd>{{ formatDate(selectedLead.createdAt) }}</dd></div> </div>
<div class="lead-detail-item"><dt>目的地</dt><dd>{{ selectedLead.destination || "未填写" }}</dd></div> <div class="lead-detail-item">
<div class="lead-detail-item"><dt>出行日期</dt><dd>{{ formatDate(selectedLead.vehicleDemand?.travelDate || selectedLead.travelDate) }}</dd></div> <dt>联系方式</dt>
<dd>{{ selectedLead.phone || "未填写" }}</dd>
</div>
<div class="lead-detail-item">
<dt>需求类型</dt>
<dd>{{ selectedLead.leadType === "vehicle" ? "用车需求" : "一般需求" }}</dd>
</div>
<div class="lead-detail-item">
<dt>提交时间</dt>
<dd>{{ formatDate(selectedLead.createdAt) }}</dd>
</div>
<div class="lead-detail-item">
<dt>目的地</dt>
<dd>{{ selectedLead.destination || "未填写" }}</dd>
</div>
<div class="lead-detail-item">
<dt>出行日期</dt>
<dd>{{ formatDate(selectedLead.vehicleDemand?.travelDate || selectedLead.travelDate) }}</dd>
</div>
</dl> </dl>
</div> </div>
<section v-if="selectedLead.vehicleDemand" class="lead-detail-section lead-detail-trip"> <section v-if="selectedLead.vehicleDemand" class="lead-detail-section lead-detail-trip">
<div class="lead-detail-section-title"><strong>用车行程</strong></div> <div class="lead-detail-section-title"><strong>用车行程</strong></div>
<p>{{ selectedLead.vehicleDemand.pickupLocation }} {{ selectedLead.vehicleDemand.dropoffLocation }}</p> <p>{{ selectedLead.vehicleDemand.pickupLocation }} {{ selectedLead.vehicleDemand.dropoffLocation }}</p>
<p>{{ selectedLead.vehicleDemand.peopleCount }} · 行李 {{ selectedLead.vehicleDemand.luggageCount ?? "未填写" }} · {{ selectedLead.vehicleDemand.vehicleOptionTitle || "未指定车型" }}</p> <p>{{ selectedLead.vehicleDemand.peopleCount }} · 行李 {{ selectedLead.vehicleDemand.luggageCount ?? "未填写" }}
· {{ selectedLead.vehicleDemand.vehicleOptionTitle || "未指定车型" }}</p>
</section> </section>
<section class="lead-detail-section lead-detail-note"> <section class="lead-detail-section lead-detail-note">
<div class="lead-detail-section-title"><strong>备注</strong></div> <div class="lead-detail-section-title"><strong>备注</strong></div>
<p>{{ selectedLead.vehicleDemand?.specialRequirements || selectedLead.note || "暂无备注" }}</p> <p>{{ selectedLead.vehicleDemand?.specialRequirements || selectedLead.note || "暂无备注" }}</p>
</section> </section>
</template> </template>
<template #footer>
<div v-if="selectedLead" class="lead-detail-footer">
<div class="lead-detail-footer-status">
<span>处理状态</span>
<el-tag :type="statusTagType(pendingStatus || selectedLead.status)" effect="plain">{{
leadStatusLabel(pendingStatus || selectedLead.status) }}</el-tag>
</div>
<div class="lead-detail-footer-actions">
<el-select v-permission="'admin:leads:update'" v-model="pendingStatus"
:loading="updatingId === selectedLead.id" aria-label="选择处理状态">
<el-option v-for="option in leadStatusOptions" :key="option.value" :label="option.label"
:value="option.value" />
</el-select>
<el-button v-permission="'admin:leads:update'" type="primary"
:disabled="!pendingStatus || pendingStatus === selectedLead.status"
:loading="updatingId === selectedLead.id" @click="submitStatus(selectedLead)">提交状态</el-button>
</div>
</div>
</template>
</el-dialog> </el-dialog>
</div> </div>
</template> </template>

View File

@@ -59,13 +59,13 @@ button, input { font: inherit; }
.single-media-heading { justify-content: space-between; } .single-media-heading { justify-content: space-between; }
.single-media-preview { width: 100%; height: 150px; border-radius: 8px; background: #f1f5f9; } .single-media-preview { width: 100%; height: 150px; border-radius: 8px; background: #f1f5f9; }
.lead-filter-card { margin-bottom: 16px; } .lead-filter-card { margin-bottom: 16px; }
.lead-filters { display: grid; grid-template-columns: 170px 170px minmax(180px, 1fr) auto; gap: 10px; } .lead-filters { display: grid; grid-template-columns: 170px 170px minmax(140px, 1fr) 60px 60px; gap: 10px; }
.lead-filters > .el-button { width: 60px; }
.lead-pagination { display: flex; justify-content: flex-end; align-items: center; gap: 14px; padding-top: 16px; color: var(--muted); font-size: 12px; } .lead-pagination { display: flex; justify-content: flex-end; align-items: center; gap: 14px; padding-top: 16px; color: var(--muted); font-size: 12px; }
.lead-detail-toolbar { display: flex; align-items: center; gap: 10px; margin-bottom: 16px; padding: 10px 12px; border: 1px solid var(--line); border-radius: 9px; background: #f8fafc; }
.lead-detail-toolbar .el-tag { flex: 0 0 auto; }
.lead-detail-toolbar .el-select { min-width: 0; flex: 1 1 auto; max-width: 310px; }
.lead-summary { display: grid; gap: 4px; min-width: 0; } .lead-summary { display: grid; gap: 4px; min-width: 0; }
.lead-summary span { overflow: hidden; color: var(--muted); font-size: 12px; text-overflow: ellipsis; white-space: nowrap; } .lead-summary span { overflow: hidden; color: var(--muted); font-size: 12px; text-overflow: ellipsis; white-space: nowrap; }
.lead-detail-dialog .el-dialog__body { padding-bottom: 16px; }
.lead-detail-dialog .el-dialog__footer { padding-top: 0; }
.lead-detail-card { overflow: hidden; margin-top: 0; border: 1px solid var(--line); border-radius: 10px; background: var(--surface); } .lead-detail-card { overflow: hidden; margin-top: 0; border: 1px solid var(--line); border-radius: 10px; background: var(--surface); }
.lead-detail-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 1px; margin: 0; background: var(--line); } .lead-detail-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 1px; margin: 0; background: var(--line); }
.lead-detail-item { min-width: 0; padding: 15px 16px; background: var(--surface); } .lead-detail-item { min-width: 0; padding: 15px 16px; background: var(--surface); }
@@ -76,6 +76,10 @@ button, input { font: inherit; }
.lead-detail-section-title::before { width: 3px; height: 14px; border-radius: 3px; background: var(--blue); content: ""; } .lead-detail-section-title::before { width: 3px; height: 14px; border-radius: 3px; background: var(--blue); content: ""; }
.lead-detail-section p { margin: 7px 0 0; overflow-wrap: anywhere; color: #475569; line-height: 1.6; } .lead-detail-section p { margin: 7px 0 0; overflow-wrap: anywhere; color: #475569; line-height: 1.6; }
.lead-detail-note { border-color: #e4eaf2; background: #f5f8fc; } .lead-detail-note { border-color: #e4eaf2; background: #f5f8fc; }
.lead-detail-footer { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding-top: 14px; border-top: 1px solid var(--line); }
.lead-detail-footer-status, .lead-detail-footer-actions { display: flex; align-items: center; gap: 10px; }
.lead-detail-footer-status { color: var(--muted); font-size: 12px; }
.lead-detail-footer-actions .el-select { width: 190px; }
.media-toolbar { justify-content: flex-end; } .media-toolbar { justify-content: flex-end; }
.media-toolbar .el-input { width: 150px; } .media-toolbar .el-input { width: 150px; }
.media-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 14px; } .media-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 14px; }
@@ -198,7 +202,7 @@ button, input { font: inherit; }
@media (max-width: 880px) { .wanfa-workspace { grid-template-columns: 1fr; } } @media (max-width: 880px) { .wanfa-workspace { grid-template-columns: 1fr; } }
@media (max-width: 980px) { .media-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); } } @media (max-width: 980px) { .media-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); } }
@media (max-width: 760px) { .concierge-row { align-items: flex-start; flex-direction: column; } .concierge-actions { justify-content: flex-start; } .lead-filters { grid-template-columns: 1fr 1fr; } .lead-filters .el-input { grid-column: 1 / -1; } .operations-grid { grid-template-columns: 1fr; } .media-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } } @media (max-width: 760px) { .concierge-row { align-items: flex-start; flex-direction: column; } .concierge-actions { justify-content: flex-start; } .lead-filters { grid-template-columns: 1fr 1fr; } .lead-filters .el-input { grid-column: 1 / -1; } .operations-grid { grid-template-columns: 1fr; } .media-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
@media (max-width: 640px) { .site-config-intro { align-items: flex-start; } .site-config-intro .el-button, .page-intro-actions { width: 100%; } .page-intro-actions { justify-content: stretch; } .page-intro-actions .el-button { flex: 1; } .editor-options { grid-template-columns: 1fr; gap: 0; } .site-config-panel .panel-heading { align-items: flex-start; flex-direction: column; } .site-config-panel .panel-heading .el-button { width: 100%; } .concierge-detail-row { grid-template-columns: 1fr; } .lead-filters { grid-template-columns: 1fr; } .lead-filters .el-input { grid-column: auto; } .lead-detail-grid { grid-template-columns: 1fr; } .lead-detail-toolbar { align-items: stretch; flex-direction: column; } .lead-detail-toolbar .el-select { width: 100%; max-width: none; } .media-toolbar { align-items: stretch; flex-direction: column; width: 100%; } .media-toolbar .el-input { width: 100%; } .media-grid { grid-template-columns: 1fr; } } @media (max-width: 640px) { .site-config-intro { align-items: flex-start; } .site-config-intro .el-button, .page-intro-actions { width: 100%; } .page-intro-actions { justify-content: stretch; } .page-intro-actions .el-button { flex: 1; } .editor-options { grid-template-columns: 1fr; gap: 0; } .site-config-panel .panel-heading { align-items: flex-start; flex-direction: column; } .site-config-panel .panel-heading .el-button { width: 100%; } .concierge-detail-row { grid-template-columns: 1fr; } .lead-filters { grid-template-columns: 1fr; } .lead-filters .el-input { grid-column: auto; } .lead-detail-grid { grid-template-columns: 1fr; } .lead-detail-footer { align-items: flex-start; flex-direction: column; } .lead-detail-footer-actions { width: 100%; } .lead-detail-footer-actions .el-select { min-width: 0; flex: 1; width: auto; } .media-toolbar { align-items: stretch; flex-direction: column; width: 100%; } .media-toolbar .el-input { width: 100%; } .media-grid { grid-template-columns: 1fr; } }
/* RuoYi-compatible shell: fixed sidebar, compact navbar, tags view and responsive content. */ /* RuoYi-compatible shell: fixed sidebar, compact navbar, tags view and responsive content. */
.admin-layout { .admin-layout {