chore(hmos): 导入生产环境鸿蒙工程
- 以 D:\SmartSenior 生产工程为源,纳入 AppScope、entry、cjagent、hvigor 及根配置 - 移除 build-profile.json5 的 signingConfigs(p12/p7b/cer 路径与加密口令)及 products.signingConfig 引用 - 不纳入本地与开发产物:local.properties、.idea、.hvigor、.codegenie、demo、backup_a2a_native、SESSION_LOG.md、build_err.txt、build/、oh_modules - .gitignore 追加 .codegenie 与密钥文件通配(*.p12/*.p7b/*.cer/*.jks/*.keystore/*.pem)
This commit is contained in:
@@ -0,0 +1,994 @@
|
||||
// 销售 · 工作台(ArkTS 严格类型,无 any)
|
||||
// 接口:GET /market/leads/、/market/contracts/;PATCH /market/leads/{id}/;POST /market/leads/
|
||||
import { promptAction, router } from '@kit.ArkUI';
|
||||
import { HdsNavigation, HdsNavigationTitleMode, ScrollEffectType } from '@kit.UIDesignKit';
|
||||
import { hdsMaterial } from '@hms.hds.hdsMaterial';
|
||||
import { BusinessError } from '@kit.BasicServicesKit';
|
||||
import { Page, normalizeError } from '../../common/ApiConstants';
|
||||
import { api, RequestError } from '../../utils/Request';
|
||||
|
||||
interface SalesLead {
|
||||
id: number;
|
||||
name: string;
|
||||
gender: string;
|
||||
age: number | null;
|
||||
contact_method: string;
|
||||
status: string;
|
||||
deal_amount: number | null;
|
||||
current_sales_name: string;
|
||||
latest_note: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
interface Contract {
|
||||
id: number;
|
||||
customer_name: string;
|
||||
sales_name: string;
|
||||
contract_amount: number | null;
|
||||
contract_code: string;
|
||||
contract_date_range: string;
|
||||
signed_date: string;
|
||||
}
|
||||
|
||||
interface FollowPayload {
|
||||
status: string;
|
||||
latest_note?: string;
|
||||
deal_amount?: string;
|
||||
}
|
||||
|
||||
interface NewLeadPayload {
|
||||
name: string;
|
||||
visit_date: string;
|
||||
gender: string;
|
||||
age: number | null;
|
||||
contact_method: string;
|
||||
id_card_number: string;
|
||||
original_follower: string;
|
||||
is_elderly_consultation: boolean;
|
||||
is_key_person: boolean | null;
|
||||
information_sources: Array<string>;
|
||||
status: string;
|
||||
deal_amount: string | null;
|
||||
latest_note: string;
|
||||
last_follow_up_at: string;
|
||||
}
|
||||
|
||||
const GENDER_OPTIONS: Array<string> = ['unknown', 'male', 'female'];
|
||||
const KEY_PERSON_OPTIONS: Array<string> = ['', 'yes', 'no'];
|
||||
const SOURCE_OPTIONS: Array<string> = ['美团', '活动', '摊位', '单张', '路过', '朋友', '小红书', '户外广告', '其他'];
|
||||
const LEAD_STATUS_OPTIONS: Array<string> = ['pending', 'in_progress', 'won', 'lost'];
|
||||
|
||||
function statusLabel(status: string): string {
|
||||
if (status === 'won') {
|
||||
return '成交';
|
||||
}
|
||||
if (status === 'in_progress') {
|
||||
return '跟进中';
|
||||
}
|
||||
if (status === 'lost') {
|
||||
return '失败';
|
||||
}
|
||||
return '待跟进';
|
||||
}
|
||||
|
||||
function statusColor(status: string): string {
|
||||
if (status === 'won') {
|
||||
return '#34C759';
|
||||
}
|
||||
if (status === 'in_progress') {
|
||||
return '#007AFF';
|
||||
}
|
||||
if (status === 'lost') {
|
||||
return '#8E8E93';
|
||||
}
|
||||
return '#FF9500';
|
||||
}
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
if (!iso) {
|
||||
return '--';
|
||||
}
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) {
|
||||
return '--';
|
||||
}
|
||||
const mm = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const dd = String(d.getDate()).padStart(2, '0');
|
||||
return `${mm}/${dd}`;
|
||||
}
|
||||
|
||||
function amountText(a: number | null): string {
|
||||
return formatAmount(a);
|
||||
}
|
||||
|
||||
// 金额规范化:去前导零、最多两位小数、千分位分组(114514.00 → ¥114,514)
|
||||
export function formatAmount(a: string | number | null | undefined): string {
|
||||
if (a === null || a === undefined || a === '') {
|
||||
return '--';
|
||||
}
|
||||
const n = Number(a);
|
||||
if (Number.isNaN(n) || !Number.isFinite(n)) {
|
||||
return `¥${a}`;
|
||||
}
|
||||
const abs = Math.abs(n);
|
||||
const fixed = Number.isInteger(abs) ? abs.toFixed(0) : abs.toFixed(2);
|
||||
const parts = fixed.split('.');
|
||||
let grouped = '';
|
||||
const intPart = parts[0];
|
||||
for (let i = 0; i < intPart.length; i++) {
|
||||
const idxFromEnd = intPart.length - i;
|
||||
grouped += intPart[i];
|
||||
if (idxFromEnd > 1 && idxFromEnd % 3 === 1) {
|
||||
grouped += ',';
|
||||
}
|
||||
}
|
||||
const dec = parts.length > 1 ? `.${parts[1]}` : '';
|
||||
return `${n < 0 ? '-' : ''}¥${grouped}${dec}`;
|
||||
}
|
||||
|
||||
// 金额输入规范化:'0114514.00' → '114514'、'01.5' → '1.5';非法输入返回空串
|
||||
export function normalizeAmountInput(raw: string): string {
|
||||
const n = Number(raw);
|
||||
if (raw.length === 0 || Number.isNaN(n) || !Number.isFinite(n) || n < 0) {
|
||||
return '';
|
||||
}
|
||||
return String(n);
|
||||
}
|
||||
|
||||
function genderLabel(g: string): string {
|
||||
if (g === 'male') {
|
||||
return '男';
|
||||
}
|
||||
if (g === 'female') {
|
||||
return '女';
|
||||
}
|
||||
return '未知';
|
||||
}
|
||||
|
||||
function keyPersonLabel(k: string): string {
|
||||
if (k === 'yes') {
|
||||
return '是';
|
||||
}
|
||||
if (k === 'no') {
|
||||
return '否';
|
||||
}
|
||||
return '未填写';
|
||||
}
|
||||
|
||||
@Entry
|
||||
@Component
|
||||
struct SalesHome {
|
||||
@State leads: Array<SalesLead> = [];
|
||||
@State contracts: Array<Contract> = [];
|
||||
@State loading: boolean = true;
|
||||
@State showSheet: boolean = false;
|
||||
@State sheetMode: string = 'new';
|
||||
@State followTarget: SalesLead | null = null;
|
||||
@State newName: string = '';
|
||||
@State newPhone: string = '';
|
||||
@State noteText: string = '';
|
||||
@State followStatus: string = 'in_progress';
|
||||
@State sheetDealAmount: string = '';
|
||||
@State submitting: boolean = false;
|
||||
// 新建线索字段(对齐 Web)
|
||||
@State visitDate: string = '';
|
||||
@State gender: string = 'unknown';
|
||||
@State ageText: string = '';
|
||||
@State idCard: string = '';
|
||||
@State originalFollower: string = '';
|
||||
@State isElderlyConsultation: boolean = false;
|
||||
@State isKeyPerson: string = '';
|
||||
@State informationSources: Array<string> = [];
|
||||
@State dealAmount: string = '';
|
||||
@State lastFollowUpAt: string = '';
|
||||
private scroller: Scroller = new Scroller();
|
||||
|
||||
aboutToAppear(): void {
|
||||
this.load();
|
||||
}
|
||||
|
||||
private async load(): Promise<void> {
|
||||
this.loading = true;
|
||||
try {
|
||||
const results: Array<Page<SalesLead> | Page<Contract> | null> = await Promise.all([
|
||||
api.get<Page<SalesLead>>('/market/leads/', { page_size: 50, ordering: '-created_at' }).catch((): Page<SalesLead> | null => null),
|
||||
api.get<Page<Contract>>('/market/contracts/', { page_size: 50 }).catch((): Page<Contract> | null => null),
|
||||
]);
|
||||
const l = results[0] as Page<SalesLead> | null;
|
||||
const c = results[1] as Page<Contract> | null;
|
||||
this.leads = l ? l.results : [];
|
||||
this.contracts = c ? c.results : [];
|
||||
console.info(`[SalesHome] leads=${this.leads.length} contracts=${this.contracts.length} rawL=${l ? 'ok' : 'null'} rawC=${c ? 'ok' : 'null'}`);
|
||||
} catch (err) {
|
||||
const message = err instanceof RequestError ? err.message : normalizeError(err as BusinessError | Error);
|
||||
promptAction.showToast({ message: message });
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
private totalLeads(): number {
|
||||
return this.leads.length;
|
||||
}
|
||||
private pendingCount(): number {
|
||||
return this.leads.filter((l: SalesLead) => l.status === 'pending').length;
|
||||
}
|
||||
private wonCount(): number {
|
||||
return this.leads.filter((l: SalesLead) => l.status === 'won').length;
|
||||
}
|
||||
private wonAmount(): number {
|
||||
let sum = 0;
|
||||
this.leads.forEach((l: SalesLead) => {
|
||||
if (l.status === 'won' && l.deal_amount !== null && l.deal_amount !== undefined) {
|
||||
// 后端 deal_amount 可能是字符串 "114514.00",必须转数字再累加,否则字符串拼接
|
||||
sum += Number(l.deal_amount);
|
||||
}
|
||||
});
|
||||
return sum;
|
||||
}
|
||||
|
||||
private openNewLead(): void {
|
||||
this.newName = '';
|
||||
this.newPhone = '';
|
||||
this.visitDate = '';
|
||||
this.gender = 'unknown';
|
||||
this.ageText = '';
|
||||
this.idCard = '';
|
||||
this.originalFollower = '';
|
||||
this.isElderlyConsultation = false;
|
||||
this.isKeyPerson = '';
|
||||
this.informationSources = [];
|
||||
this.dealAmount = '';
|
||||
this.lastFollowUpAt = '';
|
||||
this.noteText = '';
|
||||
this.followStatus = 'pending';
|
||||
this.sheetMode = 'new';
|
||||
this.showSheet = true;
|
||||
}
|
||||
|
||||
private keyPersonValue(): boolean | null {
|
||||
if (this.isKeyPerson === 'yes') {
|
||||
return true;
|
||||
}
|
||||
if (this.isKeyPerson === 'no') {
|
||||
return false;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private toggleSource(source: string): void {
|
||||
if (this.informationSources.indexOf(source) >= 0) {
|
||||
this.informationSources = this.informationSources.filter((s: string) => s !== source);
|
||||
} else {
|
||||
this.informationSources = this.informationSources.concat([source]);
|
||||
}
|
||||
}
|
||||
|
||||
private pickVisitDate(): void {
|
||||
DatePickerDialog.show({
|
||||
start: new Date('2020-01-01'),
|
||||
end: new Date('2035-12-31'),
|
||||
selected: new Date(),
|
||||
onDateAccept: (value: Date) => {
|
||||
const mm = String(value.getMonth() + 1).padStart(2, '0');
|
||||
const dd = String(value.getDate()).padStart(2, '0');
|
||||
this.visitDate = `${value.getFullYear()}-${mm}-${dd}`;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private pickLastFollowUp(): void {
|
||||
DatePickerDialog.show({
|
||||
start: new Date('2020-01-01'),
|
||||
end: new Date('2035-12-31'),
|
||||
selected: new Date(),
|
||||
onDateAccept: (value: Date) => {
|
||||
const mm = String(value.getMonth() + 1).padStart(2, '0');
|
||||
const dd = String(value.getDate()).padStart(2, '0');
|
||||
const hh = String(value.getHours()).padStart(2, '0');
|
||||
const mi = String(value.getMinutes()).padStart(2, '0');
|
||||
this.lastFollowUpAt = `${value.getFullYear()}-${mm}-${dd}T${hh}:${mi}:00`;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private async submitNewLead(): Promise<void> {
|
||||
if (this.submitting) {
|
||||
return;
|
||||
}
|
||||
if (this.newName.trim().length === 0) {
|
||||
promptAction.showToast({ message: '请输入客户姓名' });
|
||||
return;
|
||||
}
|
||||
if (this.isElderlyConsultation && this.idCard.trim().length === 0) {
|
||||
promptAction.showToast({ message: '长者咨询需填写身份证号' });
|
||||
return;
|
||||
}
|
||||
this.submitting = true;
|
||||
try {
|
||||
const payload: NewLeadPayload = {
|
||||
name: this.newName.trim(),
|
||||
visit_date: this.visitDate,
|
||||
gender: this.gender,
|
||||
age: this.ageText.trim().length > 0 ? Number(this.ageText.trim()) : null,
|
||||
contact_method: this.newPhone.trim(),
|
||||
id_card_number: this.idCard.trim(),
|
||||
original_follower: this.originalFollower.trim(),
|
||||
is_elderly_consultation: this.isElderlyConsultation,
|
||||
is_key_person: this.keyPersonValue(),
|
||||
information_sources: this.informationSources,
|
||||
status: this.followStatus,
|
||||
deal_amount: this.followStatus === 'won' ? normalizeAmountInput(this.dealAmount.trim()) : null,
|
||||
latest_note: this.noteText.trim(),
|
||||
last_follow_up_at: this.lastFollowUpAt,
|
||||
};
|
||||
await api.post('/market/leads/', payload);
|
||||
this.showSheet = false;
|
||||
promptAction.showToast({ message: '线索已创建' });
|
||||
await this.load();
|
||||
} catch (err) {
|
||||
const message = err instanceof RequestError ? err.message : normalizeError(err as BusinessError | Error);
|
||||
promptAction.showToast({ message: message });
|
||||
} finally {
|
||||
this.submitting = false;
|
||||
}
|
||||
}
|
||||
|
||||
private openFollow(lead: SalesLead): void {
|
||||
this.followTarget = lead;
|
||||
this.noteText = '';
|
||||
this.followStatus = lead.status;
|
||||
this.sheetDealAmount = lead.status === 'won' && lead.deal_amount !== null && lead.deal_amount !== undefined
|
||||
? normalizeAmountInput(String(lead.deal_amount))
|
||||
: '';
|
||||
this.sheetMode = 'follow';
|
||||
this.showSheet = true;
|
||||
}
|
||||
|
||||
private async submitFollow(): Promise<void> {
|
||||
if (this.submitting || this.followTarget === null) {
|
||||
return;
|
||||
}
|
||||
if (this.followStatus === 'won' && normalizeAmountInput(this.sheetDealAmount.trim()).length === 0) {
|
||||
promptAction.showToast({ message: '标记成交请填写成交金额' });
|
||||
return;
|
||||
}
|
||||
this.submitting = true;
|
||||
const id = this.followTarget.id;
|
||||
try {
|
||||
const body: FollowPayload = {
|
||||
status: this.followStatus,
|
||||
};
|
||||
if (this.noteText.trim().length > 0) {
|
||||
body.latest_note = this.noteText.trim();
|
||||
}
|
||||
if (this.followStatus === 'won') {
|
||||
body.deal_amount = normalizeAmountInput(this.sheetDealAmount.trim());
|
||||
}
|
||||
await api.patch(`/market/leads/${id}/`, body);
|
||||
this.showSheet = false;
|
||||
promptAction.showToast({ message: '已更新' });
|
||||
await this.load();
|
||||
} catch (err) {
|
||||
const message = err instanceof RequestError ? err.message : normalizeError(err as BusinessError | Error);
|
||||
promptAction.showToast({ message: message });
|
||||
} finally {
|
||||
this.submitting = false;
|
||||
}
|
||||
}
|
||||
|
||||
@Builder
|
||||
LeadCard(lead: SalesLead) {
|
||||
Column({ space: 6 }) {
|
||||
Row({ space: 8 }) {
|
||||
Text(lead.name)
|
||||
.fontSize(16)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
if (lead.age !== null && lead.age !== undefined) {
|
||||
Text(`${lead.age}岁`)
|
||||
.fontSize(13)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
}
|
||||
Blank()
|
||||
Text(statusLabel(lead.status))
|
||||
.fontSize(12)
|
||||
.fontColor(statusColor(lead.status))
|
||||
}
|
||||
.width('100%')
|
||||
|
||||
if (lead.contact_method && lead.contact_method.length > 0) {
|
||||
Text(`📞 ${lead.contact_method}`)
|
||||
.fontSize(13)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
.width('100%')
|
||||
}
|
||||
if (lead.latest_note && lead.latest_note.length > 0) {
|
||||
Text(lead.latest_note)
|
||||
.fontSize(13)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
.maxLines(1)
|
||||
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||
.width('100%')
|
||||
}
|
||||
|
||||
Row({ space: 10 }) {
|
||||
Text(amountText(lead.deal_amount))
|
||||
.fontSize(13)
|
||||
.fontColor('#FF9500')
|
||||
Blank()
|
||||
Button('详情')
|
||||
.height(30)
|
||||
.fontSize(12)
|
||||
.fontColor($r('app.color.accent_blue'))
|
||||
.backgroundColor($r('app.color.background'))
|
||||
.borderRadius(8)
|
||||
.onClick(() => {
|
||||
router.pushUrl({
|
||||
url: 'pages/sales/SalesLeadDetail',
|
||||
params: { leadId: lead.id },
|
||||
}).catch((err: BusinessError) => {
|
||||
promptAction.showToast({ message: `页面打开失败:${normalizeError(err)}` });
|
||||
});
|
||||
})
|
||||
Button('跟进')
|
||||
.height(30)
|
||||
.fontSize(12)
|
||||
.fontColor('#FFFFFF')
|
||||
.backgroundColor($r('app.color.accent_blue'))
|
||||
.borderRadius(8)
|
||||
.onClick(() => {
|
||||
this.openFollow(lead);
|
||||
})
|
||||
}
|
||||
.width('100%')
|
||||
}
|
||||
.width('100%')
|
||||
.padding(14)
|
||||
.backgroundColor($r('app.color.background_card'))
|
||||
.borderRadius(12)
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
}
|
||||
|
||||
@Builder
|
||||
ContractCard(c: Contract) {
|
||||
Column({ space: 6 }) {
|
||||
Row({ space: 8 }) {
|
||||
Text(c.customer_name)
|
||||
.fontSize(16)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
Blank()
|
||||
Text(amountText(c.contract_amount))
|
||||
.fontSize(14)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor('#FF9500')
|
||||
}
|
||||
.width('100%')
|
||||
if (c.contract_code && c.contract_code.length > 0) {
|
||||
Text(c.contract_code)
|
||||
.fontSize(12)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
.width('100%')
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.padding(14)
|
||||
.backgroundColor($r('app.color.background_card'))
|
||||
.borderRadius(12)
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
}
|
||||
|
||||
@Builder
|
||||
FieldInput(label: string, text: string, onChangeCb: (v: string) => void, type: string = '') {
|
||||
Row({ space: 12 }) {
|
||||
Text(label)
|
||||
.fontSize(14)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
.width(88)
|
||||
TextInput({ placeholder: label, text: text })
|
||||
.placeholderColor($r('app.color.inactive_tab'))
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
.fontSize(15)
|
||||
.height(44)
|
||||
.layoutWeight(1)
|
||||
.padding({ left: 12, right: 12 })
|
||||
.backgroundColor($r('app.color.background'))
|
||||
.borderRadius(10)
|
||||
.type(type === 'number' ? InputType.Number : (type === 'phone' ? InputType.PhoneNumber : InputType.Normal))
|
||||
.onChange(onChangeCb)
|
||||
}
|
||||
.width('100%')
|
||||
.padding(12)
|
||||
.backgroundColor($r('app.color.background'))
|
||||
.borderRadius(10)
|
||||
}
|
||||
|
||||
@Builder
|
||||
SheetContent() {
|
||||
if (this.sheetMode === 'new') {
|
||||
Scroll() {
|
||||
Column({ space: 10 }) {
|
||||
Text('新建线索')
|
||||
.fontSize(18)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
.width('100%')
|
||||
|
||||
// 来访日期
|
||||
Row({ space: 12 }) {
|
||||
Text('来访日期')
|
||||
.fontSize(14)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
.width(88)
|
||||
Text(this.visitDate.length > 0 ? this.visitDate : '选择日期')
|
||||
.fontSize(15)
|
||||
.fontColor(this.visitDate.length > 0 ? $r('app.color.text_primary') : $r('app.color.inactive_tab'))
|
||||
.layoutWeight(1)
|
||||
Text('›')
|
||||
.fontSize(18)
|
||||
.fontColor($r('app.color.inactive_tab'))
|
||||
}
|
||||
.width('100%')
|
||||
.padding(12)
|
||||
.backgroundColor($r('app.color.background'))
|
||||
.borderRadius(10)
|
||||
.onClick(() => {
|
||||
this.pickVisitDate();
|
||||
})
|
||||
|
||||
this.FieldInput('姓名', this.newName, (v: string) => { this.newName = v; })
|
||||
|
||||
// 性别
|
||||
Row({ space: 12 }) {
|
||||
Text('性别')
|
||||
.fontSize(14)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
.width(88)
|
||||
Row({ space: 8 }) {
|
||||
ForEach(GENDER_OPTIONS, (g: string) => {
|
||||
Text(genderLabel(g))
|
||||
.fontSize(13)
|
||||
.fontColor(this.gender === g ? '#FFFFFF' : $r('app.color.text_primary'))
|
||||
.padding({ left: 12, right: 12, top: 6, bottom: 6 })
|
||||
.backgroundColor(this.gender === g ? $r('app.color.accent_blue') : $r('app.color.background_card'))
|
||||
.borderRadius(8)
|
||||
.onClick(() => {
|
||||
this.gender = g;
|
||||
})
|
||||
}, (g: string) => g)
|
||||
}
|
||||
.layoutWeight(1)
|
||||
}
|
||||
.width('100%')
|
||||
.padding(12)
|
||||
.backgroundColor($r('app.color.background'))
|
||||
.borderRadius(10)
|
||||
|
||||
this.FieldInput('年龄', this.ageText, (v: string) => { this.ageText = v; }, 'number')
|
||||
this.FieldInput('联系方式', this.newPhone, (v: string) => { this.newPhone = v; }, 'phone')
|
||||
this.FieldInput('身份证号', this.idCard, (v: string) => { this.idCard = v; })
|
||||
this.FieldInput('原表跟进人', this.originalFollower, (v: string) => { this.originalFollower = v; })
|
||||
|
||||
// 是否为长者咨询
|
||||
Row({ space: 12 }) {
|
||||
Text('长者咨询')
|
||||
.fontSize(14)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
.width(88)
|
||||
Row({ space: 8 }) {
|
||||
ForEach([false, true], (v: boolean) => {
|
||||
Text(v ? '是' : '否')
|
||||
.fontSize(13)
|
||||
.fontColor(this.isElderlyConsultation === v ? '#FFFFFF' : $r('app.color.text_primary'))
|
||||
.padding({ left: 12, right: 12, top: 6, bottom: 6 })
|
||||
.backgroundColor(this.isElderlyConsultation === v ? $r('app.color.accent_blue') : $r('app.color.background_card'))
|
||||
.borderRadius(8)
|
||||
.onClick(() => {
|
||||
this.isElderlyConsultation = v;
|
||||
})
|
||||
}, (v: boolean) => String(v))
|
||||
}
|
||||
.layoutWeight(1)
|
||||
}
|
||||
.width('100%')
|
||||
.padding(12)
|
||||
.backgroundColor($r('app.color.background'))
|
||||
.borderRadius(10)
|
||||
|
||||
// 是否重点关注
|
||||
Row({ space: 12 }) {
|
||||
Text('重点关注')
|
||||
.fontSize(14)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
.width(88)
|
||||
Row({ space: 8 }) {
|
||||
ForEach(KEY_PERSON_OPTIONS, (k: string) => {
|
||||
Text(keyPersonLabel(k))
|
||||
.fontSize(13)
|
||||
.fontColor(this.isKeyPerson === k ? '#FFFFFF' : $r('app.color.text_primary'))
|
||||
.padding({ left: 12, right: 12, top: 6, bottom: 6 })
|
||||
.backgroundColor(this.isKeyPerson === k ? $r('app.color.accent_blue') : $r('app.color.background_card'))
|
||||
.borderRadius(8)
|
||||
.onClick(() => {
|
||||
this.isKeyPerson = k;
|
||||
})
|
||||
}, (k: string) => k)
|
||||
}
|
||||
.layoutWeight(1)
|
||||
}
|
||||
.width('100%')
|
||||
.padding(12)
|
||||
.backgroundColor($r('app.color.background'))
|
||||
.borderRadius(10)
|
||||
|
||||
// 信息来源(多选)
|
||||
Column({ space: 8 }) {
|
||||
Text('信息来源')
|
||||
.fontSize(14)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
.width('100%')
|
||||
Flex({ wrap: FlexWrap.Wrap }) {
|
||||
ForEach(SOURCE_OPTIONS, (s: string) => {
|
||||
Text(s)
|
||||
.fontSize(13)
|
||||
.fontColor(this.informationSources.indexOf(s) >= 0 ? '#FFFFFF' : $r('app.color.text_primary'))
|
||||
.padding({ left: 12, right: 12, top: 6, bottom: 6 })
|
||||
.backgroundColor(this.informationSources.indexOf(s) >= 0 ? $r('app.color.accent_blue') : $r('app.color.background_card'))
|
||||
.borderRadius(8)
|
||||
.margin({ right: 8, bottom: 8 })
|
||||
.onClick(() => {
|
||||
this.toggleSource(s);
|
||||
})
|
||||
}, (s: string) => s)
|
||||
}
|
||||
.width('100%')
|
||||
}
|
||||
.width('100%')
|
||||
.padding(12)
|
||||
.backgroundColor($r('app.color.background'))
|
||||
.borderRadius(10)
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
|
||||
// 状态
|
||||
Row({ space: 12 }) {
|
||||
Text('状态')
|
||||
.fontSize(14)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
.width(88)
|
||||
Flex({ wrap: FlexWrap.Wrap }) {
|
||||
ForEach(LEAD_STATUS_OPTIONS, (s: string) => {
|
||||
Text(statusLabel(s))
|
||||
.fontSize(13)
|
||||
.fontColor(this.followStatus === s ? '#FFFFFF' : $r('app.color.text_primary'))
|
||||
.padding({ left: 12, right: 12, top: 6, bottom: 6 })
|
||||
.backgroundColor(this.followStatus === s ? $r('app.color.accent_blue') : $r('app.color.background_card'))
|
||||
.borderRadius(8)
|
||||
.margin({ right: 8, bottom: 8 })
|
||||
.onClick(() => {
|
||||
this.followStatus = s;
|
||||
})
|
||||
}, (s: string) => s)
|
||||
}
|
||||
.layoutWeight(1)
|
||||
}
|
||||
.width('100%')
|
||||
.padding(12)
|
||||
.backgroundColor($r('app.color.background'))
|
||||
.borderRadius(10)
|
||||
|
||||
if (this.followStatus === 'won') {
|
||||
this.FieldInput('成交金额', this.dealAmount, (v: string) => { this.dealAmount = v; }, 'number')
|
||||
}
|
||||
|
||||
// 备注
|
||||
Column({ space: 8 }) {
|
||||
Text('备注')
|
||||
.fontSize(14)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
.width('100%')
|
||||
TextArea({ placeholder: '填写最新跟进情况', text: this.noteText })
|
||||
.placeholderColor($r('app.color.inactive_tab'))
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
.height(70)
|
||||
.padding(12)
|
||||
.backgroundColor($r('app.color.background'))
|
||||
.borderRadius(10)
|
||||
.onChange((value: string) => {
|
||||
this.noteText = value;
|
||||
})
|
||||
}
|
||||
.width('100%')
|
||||
.padding(12)
|
||||
.backgroundColor($r('app.color.background'))
|
||||
.borderRadius(10)
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
|
||||
// 最后跟进时间
|
||||
Row({ space: 12 }) {
|
||||
Text('最后跟进')
|
||||
.fontSize(14)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
.width(88)
|
||||
Text(this.lastFollowUpAt.length > 0 ? this.lastFollowUpAt : '选择时间')
|
||||
.fontSize(15)
|
||||
.fontColor(this.lastFollowUpAt.length > 0 ? $r('app.color.text_primary') : $r('app.color.inactive_tab'))
|
||||
.layoutWeight(1)
|
||||
Text('›')
|
||||
.fontSize(18)
|
||||
.fontColor($r('app.color.inactive_tab'))
|
||||
}
|
||||
.width('100%')
|
||||
.padding(12)
|
||||
.backgroundColor($r('app.color.background'))
|
||||
.borderRadius(10)
|
||||
.onClick(() => {
|
||||
this.pickLastFollowUp();
|
||||
})
|
||||
|
||||
Button(this.submitting ? '提交中...' : '创建线索')
|
||||
.width('100%')
|
||||
.height(48)
|
||||
.fontSize(16)
|
||||
.fontColor('#FFFFFF')
|
||||
.backgroundColor($r('app.color.accent_blue'))
|
||||
.borderRadius(12)
|
||||
.enabled(!this.submitting)
|
||||
.onClick(() => {
|
||||
this.submitNewLead();
|
||||
})
|
||||
}
|
||||
.width('100%')
|
||||
.padding(20)
|
||||
}
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
.scrollBar(BarState.Auto)
|
||||
.edgeEffect(EdgeEffect.Spring)
|
||||
} else {
|
||||
Column({ space: 12 }) {
|
||||
Text(`跟进:${this.followTarget ? this.followTarget.name : ''}`)
|
||||
.fontSize(18)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
Row({ space: 8 }) {
|
||||
ForEach(['pending', 'in_progress', 'won', 'lost'], (s: string) => {
|
||||
Text(statusLabel(s))
|
||||
.fontSize(13)
|
||||
.fontColor(this.followStatus === s ? '#FFFFFF' : $r('app.color.text_primary'))
|
||||
.padding({ left: 12, right: 12, top: 6, bottom: 6 })
|
||||
.backgroundColor(this.followStatus === s ? $r('app.color.accent_blue') : $r('app.color.background'))
|
||||
.borderRadius(8)
|
||||
.onClick(() => {
|
||||
this.followStatus = s;
|
||||
})
|
||||
}, (s: string) => s)
|
||||
}
|
||||
.width('100%')
|
||||
if (this.followStatus === 'won') {
|
||||
this.FieldInput('成交金额', this.sheetDealAmount, (v: string) => { this.sheetDealAmount = v; }, 'number')
|
||||
}
|
||||
TextArea({ placeholder: '跟进备注', text: this.noteText })
|
||||
.placeholderColor($r('app.color.inactive_tab'))
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
.height(90)
|
||||
.padding(14)
|
||||
.backgroundColor($r('app.color.background'))
|
||||
.borderRadius(10)
|
||||
.onChange((value: string) => {
|
||||
this.noteText = value;
|
||||
})
|
||||
Button(this.submitting ? '提交中...' : '保存跟进')
|
||||
.width('100%')
|
||||
.height(48)
|
||||
.fontSize(16)
|
||||
.fontColor('#FFFFFF')
|
||||
.backgroundColor($r('app.color.accent_blue'))
|
||||
.borderRadius(12)
|
||||
.enabled(!this.submitting)
|
||||
.onClick(() => {
|
||||
this.submitFollow();
|
||||
})
|
||||
}
|
||||
.width('100%')
|
||||
.padding(20)
|
||||
.backgroundColor($r('app.color.background_card'))
|
||||
.borderRadius(16)
|
||||
}
|
||||
}
|
||||
|
||||
build() {
|
||||
HdsNavigation() {
|
||||
Column() {
|
||||
Scroll(this.scroller) {
|
||||
Column({ space: 12 }) {
|
||||
// 统计
|
||||
Row() {
|
||||
Column({ space: 4 }) {
|
||||
Text(String(this.leads.length))
|
||||
.fontSize(22)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor('#007AFF')
|
||||
Text('总线索')
|
||||
.fontSize(12)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
}
|
||||
.layoutWeight(1)
|
||||
.justifyContent(FlexAlign.Center)
|
||||
.alignItems(HorizontalAlign.Center)
|
||||
|
||||
Column({ space: 4 }) {
|
||||
Text(String(this.pendingCount()))
|
||||
.fontSize(22)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor('#FF9500')
|
||||
Text('待跟进')
|
||||
.fontSize(12)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
}
|
||||
.layoutWeight(1)
|
||||
.justifyContent(FlexAlign.Center)
|
||||
.alignItems(HorizontalAlign.Center)
|
||||
|
||||
Column({ space: 4 }) {
|
||||
Text(String(this.wonCount()))
|
||||
.fontSize(22)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor('#34C759')
|
||||
Text('成交')
|
||||
.fontSize(12)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
}
|
||||
.layoutWeight(1)
|
||||
.justifyContent(FlexAlign.Center)
|
||||
.alignItems(HorizontalAlign.Center)
|
||||
|
||||
Column({ space: 4 }) {
|
||||
Text(formatAmount(this.wonAmount()))
|
||||
.fontSize(22)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor('#FF3B30')
|
||||
Text('成交额')
|
||||
.fontSize(12)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
}
|
||||
.layoutWeight(1)
|
||||
.justifyContent(FlexAlign.Center)
|
||||
.alignItems(HorizontalAlign.Center)
|
||||
}
|
||||
.width('100%')
|
||||
.padding(16)
|
||||
.backgroundColor($r('app.color.background_card'))
|
||||
.borderRadius(16)
|
||||
|
||||
Button('+ 新建线索')
|
||||
.width('100%')
|
||||
.height(44)
|
||||
.fontSize(15)
|
||||
.fontColor('#FFFFFF')
|
||||
.backgroundColor($r('app.color.accent_blue'))
|
||||
.borderRadius(12)
|
||||
.onClick(() => {
|
||||
this.openNewLead();
|
||||
})
|
||||
|
||||
// 线索列表
|
||||
Column({ space: 8 }) {
|
||||
Row() {
|
||||
Text('我的线索')
|
||||
.fontSize(16)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
Blank()
|
||||
Text(`共 ${this.leads.length} 条`)
|
||||
.fontSize(12)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
}
|
||||
.width('100%')
|
||||
if (this.leads.length === 0) {
|
||||
Text('暂无线索')
|
||||
.fontSize(13)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
.width('100%')
|
||||
.padding(16)
|
||||
.backgroundColor($r('app.color.background_card'))
|
||||
.borderRadius(12)
|
||||
} else {
|
||||
ForEach(this.leads, (lead: SalesLead) => {
|
||||
Column() {
|
||||
this.LeadCard(lead)
|
||||
}
|
||||
.width('100%')
|
||||
.onClick(() => {
|
||||
router.pushUrl({
|
||||
url: 'pages/sales/SalesLeadDetail',
|
||||
params: { leadId: lead.id },
|
||||
}).catch((err: BusinessError) => {
|
||||
promptAction.showToast({ message: `页面打开失败:${normalizeError(err)}` });
|
||||
});
|
||||
})
|
||||
}, (lead: SalesLead) => String(lead.id))
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
|
||||
// 合同
|
||||
Column({ space: 8 }) {
|
||||
Row() {
|
||||
Text('签约合同')
|
||||
.fontSize(16)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
Blank()
|
||||
Text(`共 ${this.contracts.length} 份`)
|
||||
.fontSize(12)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
}
|
||||
.width('100%')
|
||||
if (this.contracts.length === 0) {
|
||||
Text('暂无合同')
|
||||
.fontSize(13)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
.width('100%')
|
||||
.padding(16)
|
||||
.backgroundColor($r('app.color.background_card'))
|
||||
.borderRadius(12)
|
||||
} else {
|
||||
ForEach(this.contracts, (c: Contract) => {
|
||||
Column() {
|
||||
this.ContractCard(c)
|
||||
}
|
||||
.width('100%')
|
||||
}, (c: Contract) => String(c.id))
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
.margin({ bottom: 20 })
|
||||
}
|
||||
.width('100%')
|
||||
.padding({ left: 16, right: 16, top: 92, bottom: 90 })
|
||||
}
|
||||
.width('100%')
|
||||
.layoutWeight(1)
|
||||
.scrollBar(BarState.Auto)
|
||||
.edgeEffect(EdgeEffect.Spring)
|
||||
}
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
}
|
||||
.mode(NavigationMode.Stack)
|
||||
.titleBar({
|
||||
content: {
|
||||
title: {
|
||||
mainTitle: '销售工作台',
|
||||
},
|
||||
},
|
||||
style: {
|
||||
scrollEffectOpts: {
|
||||
enableScrollEffect: false,
|
||||
scrollEffectType: ScrollEffectType.GRADIENT_BLUR,
|
||||
},
|
||||
systemMaterialEffect: {
|
||||
materialType: hdsMaterial.MaterialType.ADAPTIVE,
|
||||
materialLevel: hdsMaterial.MaterialLevel.EXQUISITE,
|
||||
},
|
||||
},
|
||||
avoidLayoutSafeArea: false,
|
||||
enableComponentSafeArea: false,
|
||||
})
|
||||
.bindToScrollable([this.scroller])
|
||||
.hideBackButton(false)
|
||||
.titleMode(HdsNavigationTitleMode.MINI)
|
||||
.ignoreLayoutSafeArea([LayoutSafeAreaType.SYSTEM], [LayoutSafeAreaEdge.TOP, LayoutSafeAreaEdge.BOTTOM])
|
||||
.expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM])
|
||||
.backgroundColor($r('app.color.background'))
|
||||
.bindSheet($$this.showSheet, this.SheetContent(), {
|
||||
height: 560,
|
||||
dragBar: true,
|
||||
showClose: true,
|
||||
backgroundColor: $r('app.color.background_card'),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,582 @@
|
||||
// 销售端 · 线索详情(ArkTS 严格类型,无 any)
|
||||
// 接口:GET /market/leads/{id}/;PATCH /market/leads/{id}/(跟进)
|
||||
// 展示对齐小程序 sales/detail:基础信息 + 线索信息 + 操作信息 + 备注,底部跟进操作
|
||||
import { promptAction, router } from '@kit.ArkUI';
|
||||
import { HdsNavigation, HdsNavigationTitleMode, ScrollEffectType } from '@kit.UIDesignKit';
|
||||
import { hdsMaterial } from '@hms.hds.hdsMaterial';
|
||||
import { BusinessError } from '@kit.BasicServicesKit';
|
||||
import { normalizeError } from '../../common/ApiConstants';
|
||||
import { api, RequestError } from '../../utils/Request';
|
||||
import { formatAmount, normalizeAmountInput } from './SalesHome';
|
||||
|
||||
interface LeadDetail {
|
||||
id: number;
|
||||
name: string;
|
||||
gender: string;
|
||||
age: number | null;
|
||||
contact_method: string;
|
||||
id_card_number: string;
|
||||
original_follower: string;
|
||||
is_elderly_consultation: boolean | null;
|
||||
is_key_person: boolean | null;
|
||||
information_sources: Array<string>;
|
||||
status: string;
|
||||
deal_amount: string | number | null;
|
||||
latest_note: string;
|
||||
last_follow_up_at: string;
|
||||
visit_date: string;
|
||||
current_sales_name: string;
|
||||
current_cycle_month: string;
|
||||
linked_elderly_name: string;
|
||||
linked_elderly_account_username: string;
|
||||
created_via: string;
|
||||
created_by_name: string;
|
||||
updated_by_name: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
interface FollowPayload {
|
||||
status: string;
|
||||
latest_note?: string;
|
||||
deal_amount?: string;
|
||||
last_follow_up_at?: string;
|
||||
}
|
||||
|
||||
const STATUS_OPTIONS: Array<string> = ['pending', 'in_progress', 'won', 'lost'];
|
||||
|
||||
function statusLabel(status: string): string {
|
||||
if (status === 'won') {
|
||||
return '成交';
|
||||
}
|
||||
if (status === 'in_progress') {
|
||||
return '跟进中';
|
||||
}
|
||||
if (status === 'lost') {
|
||||
return '失败';
|
||||
}
|
||||
return '待跟进';
|
||||
}
|
||||
|
||||
function statusColor(status: string): string {
|
||||
if (status === 'won') {
|
||||
return '#34C759';
|
||||
}
|
||||
if (status === 'in_progress') {
|
||||
return '#007AFF';
|
||||
}
|
||||
if (status === 'lost') {
|
||||
return '#8E8E93';
|
||||
}
|
||||
return '#FF9500';
|
||||
}
|
||||
|
||||
function genderLabel(g: string): string {
|
||||
if (g === 'male') {
|
||||
return '男';
|
||||
}
|
||||
if (g === 'female') {
|
||||
return '女';
|
||||
}
|
||||
return '未知';
|
||||
}
|
||||
|
||||
function boolLabel(v: boolean | null): string {
|
||||
if (v === true) {
|
||||
return '是';
|
||||
}
|
||||
if (v === false) {
|
||||
return '否';
|
||||
}
|
||||
return '未填写';
|
||||
}
|
||||
|
||||
function amountText(a: string | number | null): string {
|
||||
return formatAmount(a);
|
||||
}
|
||||
|
||||
function dateLabel(iso: string): string {
|
||||
if (!iso) {
|
||||
return '未填写';
|
||||
}
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) {
|
||||
return iso;
|
||||
}
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function dateTimeLabel(iso: string): string {
|
||||
if (!iso) {
|
||||
return '未填写';
|
||||
}
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) {
|
||||
return iso;
|
||||
}
|
||||
const mm = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const dd = String(d.getDate()).padStart(2, '0');
|
||||
const hh = String(d.getHours()).padStart(2, '0');
|
||||
const mi = String(d.getMinutes()).padStart(2, '0');
|
||||
return `${mm}/${dd} ${hh}:${mi}`;
|
||||
}
|
||||
|
||||
function sourcesText(list: Array<string>): string {
|
||||
if (list.length === 0) {
|
||||
return '未填写';
|
||||
}
|
||||
return list.join('、');
|
||||
}
|
||||
|
||||
@Entry
|
||||
@Component
|
||||
struct SalesLeadDetail {
|
||||
@State lead: LeadDetail | null = null;
|
||||
@State loading: boolean = true;
|
||||
@State errorMsg: string = '';
|
||||
@State leadId: number = 0;
|
||||
@State followStatus: string = 'pending';
|
||||
@State noteText: string = '';
|
||||
@State dealAmount: string = '';
|
||||
@State lastFollowUp: string = '';
|
||||
@State submitting: boolean = false;
|
||||
private scroller: Scroller = new Scroller();
|
||||
|
||||
aboutToAppear(): void {
|
||||
const params = router.getParams() as Record<string, Object> | null | undefined;
|
||||
if (params !== null && params !== undefined) {
|
||||
this.leadId = Number(params['leadId'] ?? 0);
|
||||
}
|
||||
if (this.leadId > 0) {
|
||||
this.load();
|
||||
} else {
|
||||
this.loading = false;
|
||||
this.errorMsg = '缺少线索参数';
|
||||
}
|
||||
}
|
||||
|
||||
// 后端字段可能缺失(undefined 会让 .length 崩溃),统一归一化为空串/空数组
|
||||
private normalizeLead(res: LeadDetail): LeadDetail {
|
||||
const s = (v: string | null | undefined): string => (typeof v === 'string' ? v : '');
|
||||
const sources: Array<string> = Array.isArray(res.information_sources) ? res.information_sources : [];
|
||||
return {
|
||||
id: res.id,
|
||||
name: s(res.name),
|
||||
gender: s(res.gender),
|
||||
age: res.age !== undefined ? res.age : null,
|
||||
contact_method: s(res.contact_method),
|
||||
id_card_number: s(res.id_card_number),
|
||||
original_follower: s(res.original_follower),
|
||||
is_elderly_consultation: res.is_elderly_consultation === true ? true : (res.is_elderly_consultation === false ? false : null),
|
||||
is_key_person: res.is_key_person === true ? true : (res.is_key_person === false ? false : null),
|
||||
information_sources: sources,
|
||||
status: s(res.status) === '' ? 'pending' : s(res.status),
|
||||
deal_amount: res.deal_amount !== undefined ? res.deal_amount : null,
|
||||
latest_note: s(res.latest_note),
|
||||
last_follow_up_at: s(res.last_follow_up_at),
|
||||
visit_date: s(res.visit_date),
|
||||
current_sales_name: s(res.current_sales_name),
|
||||
current_cycle_month: s(res.current_cycle_month),
|
||||
linked_elderly_name: s(res.linked_elderly_name),
|
||||
linked_elderly_account_username: s(res.linked_elderly_account_username),
|
||||
created_via: s(res.created_via),
|
||||
created_by_name: s(res.created_by_name),
|
||||
updated_by_name: s(res.updated_by_name),
|
||||
created_at: s(res.created_at),
|
||||
updated_at: s(res.updated_at),
|
||||
};
|
||||
}
|
||||
|
||||
private async load(): Promise<void> {
|
||||
this.loading = true;
|
||||
this.errorMsg = '';
|
||||
try {
|
||||
const res = await api.get<LeadDetail>(`/market/leads/${this.leadId}/`);
|
||||
this.lead = this.normalizeLead(res);
|
||||
this.followStatus = this.lead.status;
|
||||
this.dealAmount = this.lead.status === 'won' && this.lead.deal_amount !== null && this.lead.deal_amount !== undefined
|
||||
? normalizeAmountInput(String(this.lead.deal_amount))
|
||||
: '';
|
||||
this.lastFollowUp = this.lead.last_follow_up_at;
|
||||
} catch (err) {
|
||||
this.errorMsg = err instanceof RequestError ? err.message : normalizeError(err as BusinessError | Error);
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
// 选最后跟进日期:保留原时间部分(无则用当前时刻),合成 datetime 提交
|
||||
private pickLastFollowUp(): void {
|
||||
const current = this.lastFollowUp.length > 0 && !Number.isNaN(new Date(this.lastFollowUp).getTime())
|
||||
? new Date(this.lastFollowUp)
|
||||
: new Date();
|
||||
DatePickerDialog.show({
|
||||
start: new Date('2020-01-01'),
|
||||
end: new Date('2035-12-31'),
|
||||
selected: current,
|
||||
onDateAccept: (value: Date) => {
|
||||
const mm = String(value.getMonth() + 1).padStart(2, '0');
|
||||
const dd = String(value.getDate()).padStart(2, '0');
|
||||
const hh = String(current.getHours()).padStart(2, '0');
|
||||
const mi = String(current.getMinutes()).padStart(2, '0');
|
||||
this.lastFollowUp = `${value.getFullYear()}-${mm}-${dd}T${hh}:${mi}:00`;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private async submitFollow(): Promise<void> {
|
||||
if (this.submitting || this.lead === null) {
|
||||
return;
|
||||
}
|
||||
if (this.followStatus === 'won' && normalizeAmountInput(this.dealAmount.trim()).length === 0) {
|
||||
promptAction.showToast({ message: '标记成交请填写成交金额' });
|
||||
return;
|
||||
}
|
||||
this.submitting = true;
|
||||
try {
|
||||
const body: FollowPayload = { status: this.followStatus };
|
||||
if (this.noteText.trim().length > 0) {
|
||||
body.latest_note = this.noteText.trim();
|
||||
}
|
||||
if (this.followStatus === 'won') {
|
||||
body.deal_amount = normalizeAmountInput(this.dealAmount.trim());
|
||||
}
|
||||
if (this.lastFollowUp.length > 0) {
|
||||
body.last_follow_up_at = this.lastFollowUp;
|
||||
}
|
||||
await api.patch(`/market/leads/${this.leadId}/`, body);
|
||||
promptAction.showToast({ message: '已更新' });
|
||||
this.noteText = '';
|
||||
await this.load();
|
||||
} catch (err) {
|
||||
const message = err instanceof RequestError ? err.message : normalizeError(err as BusinessError | Error);
|
||||
promptAction.showToast({ message: message });
|
||||
} finally {
|
||||
this.submitting = false;
|
||||
}
|
||||
}
|
||||
|
||||
@Builder
|
||||
Hero() {
|
||||
Column({ space: 10 }) {
|
||||
Row({ space: 8 }) {
|
||||
Text(this.lead?.name ?? '--')
|
||||
.fontSize(22)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
.layoutWeight(1)
|
||||
Text(statusLabel(this.lead?.status ?? ''))
|
||||
.fontSize(13)
|
||||
.fontWeight(FontWeight.Medium)
|
||||
.fontColor('#FFFFFF')
|
||||
.padding({ left: 12, right: 12, top: 5, bottom: 5 })
|
||||
.backgroundColor(statusColor(this.lead?.status ?? ''))
|
||||
.borderRadius(10)
|
||||
}
|
||||
.width('100%')
|
||||
|
||||
Row({ space: 8 }) {
|
||||
Text(this.lead?.current_sales_name && this.lead.current_sales_name.length > 0 ? this.lead.current_sales_name : '未分配')
|
||||
.fontSize(12)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
.padding({ left: 10, right: 10, top: 4, bottom: 4 })
|
||||
.backgroundColor($r('app.color.background'))
|
||||
.borderRadius(8)
|
||||
Text(this.lead !== null && this.lead.status === 'won' ? amountText(this.lead.deal_amount) : '未成交')
|
||||
.fontSize(12)
|
||||
.fontColor(this.lead !== null && this.lead.status === 'won' ? '#FF9500' : $r('app.color.text_secondary'))
|
||||
.padding({ left: 10, right: 10, top: 4, bottom: 4 })
|
||||
.backgroundColor($r('app.color.background'))
|
||||
.borderRadius(8)
|
||||
Blank()
|
||||
Text(`跟进 ${dateTimeLabel(this.lead?.last_follow_up_at ?? '')}`)
|
||||
.fontSize(12)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
}
|
||||
.width('100%')
|
||||
}
|
||||
.width('100%')
|
||||
.padding(16)
|
||||
.backgroundColor($r('app.color.background_card'))
|
||||
.borderRadius(16)
|
||||
}
|
||||
|
||||
@Builder
|
||||
DetailItem(label: string, value: string) {
|
||||
Column({ space: 4 }) {
|
||||
Text(label)
|
||||
.fontSize(12)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
Text((value ?? '').length > 0 ? value : '未填写')
|
||||
.fontSize(14)
|
||||
.fontWeight(FontWeight.Medium)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
.maxLines(2)
|
||||
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||
}
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
.width('50%')
|
||||
.padding({ top: 8, bottom: 8 })
|
||||
}
|
||||
|
||||
@Builder
|
||||
SectionCard(title: string) {
|
||||
Text(title)
|
||||
.fontSize(15)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
.width('100%')
|
||||
}
|
||||
|
||||
build() {
|
||||
HdsNavigation() {
|
||||
Column() {
|
||||
Scroll(this.scroller) {
|
||||
Column({ space: 12 }) {
|
||||
if (this.loading) {
|
||||
Text('详情加载中...')
|
||||
.fontSize(14)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
.width('100%')
|
||||
.padding({ top: 40 })
|
||||
.textAlign(TextAlign.Center)
|
||||
} else if (this.errorMsg.length > 0) {
|
||||
Column({ space: 8 }) {
|
||||
Text('加载失败')
|
||||
.fontSize(16)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
Text(this.errorMsg)
|
||||
.fontSize(13)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
}
|
||||
.width('100%')
|
||||
.padding({ top: 40 })
|
||||
.alignItems(HorizontalAlign.Center)
|
||||
} else if (this.lead !== null) {
|
||||
this.Hero()
|
||||
|
||||
// 基础信息
|
||||
Column({ space: 6 }) {
|
||||
this.SectionCard('基础信息')
|
||||
Flex({ wrap: FlexWrap.Wrap }) {
|
||||
this.DetailItem('来访日期', dateLabel(this.lead!.visit_date))
|
||||
this.DetailItem('性别', genderLabel(this.lead!.gender))
|
||||
this.DetailItem('年龄', this.lead!.age !== null && this.lead!.age !== undefined ? `${this.lead!.age}岁` : '未填写')
|
||||
this.DetailItem('联系方式', this.lead!.contact_method)
|
||||
this.DetailItem('身份证号', this.lead!.id_card_number)
|
||||
this.DetailItem('原表跟进人', this.lead!.original_follower)
|
||||
}
|
||||
.width('100%')
|
||||
}
|
||||
.width('100%')
|
||||
.padding(14)
|
||||
.backgroundColor($r('app.color.background_card'))
|
||||
.borderRadius(16)
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
|
||||
// 线索信息
|
||||
Column({ space: 6 }) {
|
||||
this.SectionCard('线索信息')
|
||||
Flex({ wrap: FlexWrap.Wrap }) {
|
||||
this.DetailItem('信息来源', sourcesText(this.lead!.information_sources))
|
||||
this.DetailItem('是否为长者咨询', boolLabel(this.lead!.is_elderly_consultation))
|
||||
this.DetailItem('是否重点关注', boolLabel(this.lead!.is_key_person))
|
||||
this.DetailItem('当前状态', statusLabel(this.lead!.status))
|
||||
this.DetailItem('成交金额', this.lead!.status === 'won' ? amountText(this.lead!.deal_amount) : '未成交')
|
||||
this.DetailItem('最后跟进时间', dateTimeLabel(this.lead!.last_follow_up_at))
|
||||
this.DetailItem('负责销售', this.lead!.current_sales_name)
|
||||
this.DetailItem('当前周期', this.lead!.current_cycle_month)
|
||||
this.DetailItem('关联长者档案', this.lead!.linked_elderly_name)
|
||||
this.DetailItem('长者账号', this.lead!.linked_elderly_account_username)
|
||||
}
|
||||
.width('100%')
|
||||
}
|
||||
.width('100%')
|
||||
.padding(14)
|
||||
.backgroundColor($r('app.color.background_card'))
|
||||
.borderRadius(16)
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
|
||||
// 操作信息
|
||||
Column({ space: 6 }) {
|
||||
this.SectionCard('操作信息')
|
||||
Flex({ wrap: FlexWrap.Wrap }) {
|
||||
this.DetailItem('创建方式', this.lead!.created_via === 'import' ? '导入' : '手工录入')
|
||||
this.DetailItem('创建人', this.lead!.created_by_name)
|
||||
this.DetailItem('更新人', this.lead!.updated_by_name)
|
||||
this.DetailItem('创建时间', dateTimeLabel(this.lead!.created_at))
|
||||
this.DetailItem('更新时间', dateTimeLabel(this.lead!.updated_at))
|
||||
}
|
||||
.width('100%')
|
||||
}
|
||||
.width('100%')
|
||||
.padding(14)
|
||||
.backgroundColor($r('app.color.background_card'))
|
||||
.borderRadius(16)
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
|
||||
// 备注
|
||||
Column({ space: 6 }) {
|
||||
Text('备注')
|
||||
.fontSize(15)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
.width('100%')
|
||||
Text(this.lead!.latest_note !== null && this.lead!.latest_note !== undefined && this.lead!.latest_note.trim().length > 0 ? this.lead!.latest_note.trim() : '暂无备注')
|
||||
.fontSize(14)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
.width('100%')
|
||||
}
|
||||
.width('100%')
|
||||
.padding(14)
|
||||
.backgroundColor($r('app.color.background_card'))
|
||||
.borderRadius(16)
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
|
||||
// 跟进操作
|
||||
Column({ space: 10 }) {
|
||||
Text('跟进')
|
||||
.fontSize(15)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
.width('100%')
|
||||
Row({ space: 8 }) {
|
||||
ForEach(STATUS_OPTIONS, (s: string) => {
|
||||
Text(statusLabel(s))
|
||||
.fontSize(13)
|
||||
.fontColor(this.followStatus === s ? '#FFFFFF' : $r('app.color.text_primary'))
|
||||
.padding({ left: 14, right: 14, top: 7, bottom: 7 })
|
||||
.backgroundColor(this.followStatus === s ? statusColor(s) : $r('app.color.background'))
|
||||
.borderRadius(9)
|
||||
.onClick(() => {
|
||||
this.followStatus = s;
|
||||
})
|
||||
.accessibilityLevel('yes')
|
||||
.accessibilityText(`状态设为${statusLabel(s)}${this.followStatus === s ? ',已选中' : ''}`)
|
||||
}, (s: string) => s)
|
||||
}
|
||||
.width('100%')
|
||||
|
||||
if (this.followStatus === 'won') {
|
||||
Row({ space: 12 }) {
|
||||
Text('成交金额')
|
||||
.fontSize(14)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
.width(88)
|
||||
TextInput({ placeholder: '请输入成交金额(元)', text: this.dealAmount })
|
||||
.placeholderColor($r('app.color.inactive_tab'))
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
.fontSize(15)
|
||||
.height(44)
|
||||
.layoutWeight(1)
|
||||
.padding({ left: 12, right: 12 })
|
||||
.backgroundColor($r('app.color.background'))
|
||||
.borderRadius(10)
|
||||
.type(InputType.NUMBER_DECIMAL)
|
||||
.onChange((value: string) => {
|
||||
this.dealAmount = value;
|
||||
})
|
||||
}
|
||||
.width('100%')
|
||||
.padding(12)
|
||||
.backgroundColor($r('app.color.background'))
|
||||
.borderRadius(10)
|
||||
}
|
||||
|
||||
Row({ space: 12 }) {
|
||||
Text('最后跟进')
|
||||
.fontSize(14)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
.width(88)
|
||||
Text(this.lastFollowUp.length > 0 ? dateTimeLabel(this.lastFollowUp) : '选择日期')
|
||||
.fontSize(15)
|
||||
.fontColor(this.lastFollowUp.length > 0 ? $r('app.color.text_primary') : $r('app.color.inactive_tab'))
|
||||
.layoutWeight(1)
|
||||
Text('›')
|
||||
.fontSize(18)
|
||||
.fontColor($r('app.color.inactive_tab'))
|
||||
}
|
||||
.width('100%')
|
||||
.padding(12)
|
||||
.backgroundColor($r('app.color.background'))
|
||||
.borderRadius(10)
|
||||
.onClick(() => {
|
||||
this.pickLastFollowUp();
|
||||
})
|
||||
.accessibilityLevel('yes')
|
||||
.accessibilityText(`最后跟进时间${this.lastFollowUp.length > 0 ? dateTimeLabel(this.lastFollowUp) : '未选择'},点按修改`)
|
||||
|
||||
TextArea({ placeholder: '跟进备注(选填)', text: this.noteText })
|
||||
.placeholderColor($r('app.color.inactive_tab'))
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
.height(80)
|
||||
.padding(12)
|
||||
.backgroundColor($r('app.color.background'))
|
||||
.borderRadius(10)
|
||||
.onChange((value: string) => {
|
||||
this.noteText = value;
|
||||
})
|
||||
|
||||
Button(this.submitting ? '提交中...' : '保存跟进')
|
||||
.width('100%')
|
||||
.height(46)
|
||||
.fontSize(16)
|
||||
.fontColor('#FFFFFF')
|
||||
.backgroundColor($r('app.color.accent_blue'))
|
||||
.borderRadius(12)
|
||||
.enabled(!this.submitting)
|
||||
.onClick(() => {
|
||||
this.submitFollow();
|
||||
})
|
||||
.accessibilityLevel('yes')
|
||||
.accessibilityText('保存跟进')
|
||||
.accessibilityDescription('点按保存状态与备注')
|
||||
}
|
||||
.width('100%')
|
||||
.padding(14)
|
||||
.backgroundColor($r('app.color.background_card'))
|
||||
.borderRadius(16)
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
.margin({ bottom: 20 })
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.padding({ left: 16, right: 16, top: 92, bottom: 90 })
|
||||
}
|
||||
.width('100%')
|
||||
.layoutWeight(1)
|
||||
.scrollBar(BarState.Auto)
|
||||
.edgeEffect(EdgeEffect.Spring)
|
||||
}
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
}
|
||||
.mode(NavigationMode.Stack)
|
||||
.titleBar({
|
||||
content: {
|
||||
title: {
|
||||
mainTitle: '线索详情',
|
||||
},
|
||||
},
|
||||
style: {
|
||||
scrollEffectOpts: {
|
||||
enableScrollEffect: false,
|
||||
scrollEffectType: ScrollEffectType.GRADIENT_BLUR,
|
||||
},
|
||||
systemMaterialEffect: {
|
||||
materialType: hdsMaterial.MaterialType.ADAPTIVE,
|
||||
materialLevel: hdsMaterial.MaterialLevel.EXQUISITE,
|
||||
},
|
||||
},
|
||||
avoidLayoutSafeArea: false,
|
||||
enableComponentSafeArea: false,
|
||||
})
|
||||
.bindToScrollable([this.scroller])
|
||||
.hideBackButton(false)
|
||||
.titleMode(HdsNavigationTitleMode.MINI)
|
||||
.ignoreLayoutSafeArea([LayoutSafeAreaType.SYSTEM], [LayoutSafeAreaEdge.TOP, LayoutSafeAreaEdge.BOTTOM])
|
||||
.expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM])
|
||||
.backgroundColor($r('app.color.background'))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user