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:
CAPTAIN1275
2026-09-29 18:44:37 +08:00
parent d7f0e2f8a8
commit baf307e9d5
184 changed files with 31334 additions and 0 deletions
@@ -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'))
}
}