Skip to content

Commit a108f3f

Browse files
committed
feat: 优化AI图表单位显示并新增登录验证码
1 parent 65dfa33 commit a108f3f

18 files changed

Lines changed: 1268 additions & 76 deletions

File tree

packages/mobile/src/lib/detectVisualization.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,18 +4,29 @@
44
*/
55
import type { AiVisualizationSpec } from '@/types'
66
import {
7+
formatChartMetricValue,
78
fieldToLabel,
9+
fmtCompactNum,
810
fmtNum,
911
fmtQtyWithUnit,
1012
fmtFieldValue,
1113
fmtNumRaw,
1214
getDisplayColumns,
15+
normalizeChartMetricValue,
1316
isStrategySnapshotRows,
1417
NON_METRIC_NUMERIC_FIELDS,
1518
DETAIL_RECORD_FIELDS,
1619
} from '@shared/constants/ai-field-label'
1720

18-
export { fieldToLabel, fmtNum, fmtQtyWithUnit, fmtFieldValue, fmtNumRaw }
21+
export {
22+
fieldToLabel,
23+
formatChartMetricValue,
24+
fmtCompactNum,
25+
fmtNum,
26+
fmtQtyWithUnit,
27+
fmtFieldValue,
28+
fmtNumRaw,
29+
}
1930

2031
const CHART_REQUEST_RE = /||||线||线||/
2132
const COMPARISON_REQUEST_RE = /|||.+||||||||||||/
@@ -128,7 +139,7 @@ export function prepareVisualizationRows(
128139
[xField]: xField === DERIVED_CATEGORY_FIELD
129140
? buildDerivedCategoryLabel(row, duplicateProductNames)
130141
: String(row[xField] ?? ''),
131-
...(yField ? { [yField]: Number(row[yField]) || 0 } : {}),
142+
...(yField ? { [yField]: normalizeChartMetricValue(row[yField], yField, row, rows) } : {}),
132143
}))
133144
}
134145

packages/mobile/src/pages/ai/components/AiChart.tsx

Lines changed: 25 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,12 @@ import {
88
PieChart, Pie, Cell, Legend,
99
} from 'recharts'
1010
import type { AiVisualizationSpec } from '@/types'
11-
import { fieldToLabel, fmtNum, fmtQtyWithUnit, fmtFieldValue, fmtNumRaw, prepareVisualizationRows } from '@/lib/detectVisualization'
11+
import {
12+
fieldToLabel,
13+
formatChartMetricValue,
14+
fmtFieldValue,
15+
prepareVisualizationRows,
16+
} from '@/lib/detectVisualization'
1217
import { getDisplayColumns, isSummarizableMetricField } from '@shared/constants/ai-field-label'
1318

1419
const COLORS = ['#D4A853', '#52b788', '#74c69d', '#e76f51', '#457b9d', '#e9c46a', '#a8dadc', '#f4a261']
@@ -18,8 +23,14 @@ interface Props {
1823
spec: AiVisualizationSpec
1924
}
2025

21-
function tooltipFormatter(value: number | string, name: string): [number | string, string] {
22-
return [typeof value === 'number' ? fmtNumRaw(value) : value, fieldToLabel(name)]
26+
function createMetricTickFormatter(fieldName: string, rows: Record<string, unknown>[]) {
27+
return (value: number | string) => formatChartMetricValue(value, fieldName, rows)
28+
}
29+
30+
function createTooltipFormatter(fieldName: string, rows: Record<string, unknown>[]) {
31+
return (value: number | string, name: string): [string, string] => (
32+
[formatChartMetricValue(value, fieldName, rows), fieldToLabel(name)]
33+
)
2334
}
2435

2536
function formatCellValue(field: string, value: unknown): string {
@@ -43,7 +54,7 @@ function Summary({ type, rows, spec }: { type: string; rows: Record<string, unkn
4354
const minIdx = vals.indexOf(minVal)
4455
const maxName = String(chartRows[maxIdx]?.[spec.xField] ?? '')
4556
const minName = String(chartRows[minIdx]?.[spec.xField] ?? '')
46-
text = `共 ${rows.length} 项,${yLabel}合计 ${fmtQtyWithUnit(total, spec.yField!, rows[0] ?? {}, rows)}。最高「${maxName}${fmtQtyWithUnit(maxVal, spec.yField!, rows[maxIdx] ?? {}, rows)},最低「${minName}${fmtQtyWithUnit(minVal, spec.yField!, rows[minIdx] ?? {}, rows)}。`
57+
text = `共 ${rows.length} 项,${yLabel}合计 ${formatChartMetricValue(total, spec.yField!, rows)}。最高「${maxName}${formatChartMetricValue(maxVal, spec.yField!, rows)},最低「${minName}${formatChartMetricValue(minVal, spec.yField!, rows)}。`
4758
}
4859

4960
if (type === 'line' && spec.xField && spec.yField) {
@@ -53,7 +64,7 @@ function Summary({ type, rows, spec }: { type: string; rows: Record<string, unkn
5364
const avg = total / vals.length
5465
const firstX = String(chartRows[0]?.[spec.xField] ?? '')
5566
const lastX = String(chartRows[chartRows.length - 1]?.[spec.xField] ?? '')
56-
text = `${firstX}${lastX}${rows.length} 个周期,${yLabel}均值 ${fmtQtyWithUnit(avg, spec.yField!, rows[0] ?? {}, rows)},合计 ${fmtQtyWithUnit(total, spec.yField!, rows[0] ?? {}, rows)}。`
67+
text = `${firstX}${lastX}${rows.length} 个周期,${yLabel}均值 ${formatChartMetricValue(avg, spec.yField!, rows)},合计 ${formatChartMetricValue(total, spec.yField!, rows)}。`
5768
}
5869

5970
if (type === 'pie' && spec.nameField && spec.valueField) {
@@ -65,7 +76,7 @@ function Summary({ type, rows, spec }: { type: string; rows: Record<string, unkn
6576
const total = data.reduce((s, d) => s + d.value, 0)
6677
const top = data[0]
6778
const topPct = total > 0 ? ((top.value / total) * 100).toFixed(1) : '0'
68-
text = `共 ${data.length} 类,${yLabel}合计 ${fmtQtyWithUnit(total, spec.valueField!, rows[0] ?? {}, rows)}。占比最高「${top.name}${topPct}%。`
79+
text = `共 ${data.length} 类,${yLabel}合计 ${formatChartMetricValue(total, spec.valueField!, rows)}。占比最高「${top.name}${topPct}%。`
6980
}
7081

7182
if (type === 'table') {
@@ -76,7 +87,7 @@ function Summary({ type, rows, spec }: { type: string; rows: Record<string, unkn
7687
if (isSummarizableMetricField(col, rawValues)) {
7788
const vals = rawValues.map((value) => Number(value)).filter((v) => !isNaN(v) && v !== 0)
7889
const colTotal = vals.reduce((a, b) => a + b, 0)
79-
numSummaries.push(`${fieldToLabel(col)} ${fmtQtyWithUnit(colTotal, col, rows[0] ?? {}, rows)}`)
90+
numSummaries.push(`${fieldToLabel(col)} ${formatChartMetricValue(colTotal, col, rows)}`)
8091
}
8192
}
8293
text = `共 ${rows.length} 条记录。${numSummaries.length > 0 ? '合计:' + numSummaries.join(',') + '。' : ''}`
@@ -130,8 +141,8 @@ function BarViz({ rows, xField, yField }: { rows: Record<string, unknown>[]; xFi
130141
<BarChart data={data} margin={{ top: 6, right: 8, left: -16, bottom: 36 }}>
131142
<CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--border))" />
132143
<XAxis dataKey={xField} tick={{ fontSize: 10 }} angle={-30} textAnchor="end" interval={0} />
133-
<YAxis tick={{ fontSize: 10 }} />
134-
<Tooltip formatter={tooltipFormatter} contentStyle={{ fontSize: 12 }} />
144+
<YAxis tick={{ fontSize: 10 }} tickFormatter={createMetricTickFormatter(yField, rows)} />
145+
<Tooltip formatter={createTooltipFormatter(yField, rows)} contentStyle={{ fontSize: 12 }} />
135146
<Bar dataKey={yField} name={fieldToLabel(yField)} fill="#D4A853" radius={[3, 3, 0, 0]} maxBarSize={40} />
136147
</BarChart>
137148
</ResponsiveContainer>
@@ -146,8 +157,8 @@ function LineViz({ rows, xField, yField }: { rows: Record<string, unknown>[]; xF
146157
<LineChart data={data} margin={{ top: 6, right: 8, left: -16, bottom: 36 }}>
147158
<CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--border))" />
148159
<XAxis dataKey={xField} tick={{ fontSize: 10 }} angle={-30} textAnchor="end" interval={0} />
149-
<YAxis tick={{ fontSize: 10 }} />
150-
<Tooltip formatter={tooltipFormatter} contentStyle={{ fontSize: 12 }} />
160+
<YAxis tick={{ fontSize: 10 }} tickFormatter={createMetricTickFormatter(yField, rows)} />
161+
<Tooltip formatter={createTooltipFormatter(yField, rows)} contentStyle={{ fontSize: 12 }} />
151162
<Line
152163
type="monotone"
153164
dataKey={yField}
@@ -164,8 +175,8 @@ function LineViz({ rows, xField, yField }: { rows: Record<string, unknown>[]; xF
164175

165176
// ─── 饼图 ─────────────────────────────────────────────────────────────────────
166177
function PieViz({ rows, nameField, valueField }: { rows: Record<string, unknown>[]; nameField: string; valueField: string }) {
167-
const data = prepareVisualizationRows(rows, nameField)
168-
.map((r) => ({ name: String(r[nameField] ?? ''), value: Number(r[valueField]) || 0 }))
178+
const data = prepareVisualizationRows(rows, nameField, valueField)
179+
.map((r) => ({ ...r, name: String(r[nameField] ?? ''), value: Number(r[valueField]) || 0 }))
169180
.filter((d) => d.value > 0)
170181
const total = data.reduce((s, d) => s + d.value, 0)
171182

@@ -187,7 +198,7 @@ function PieViz({ rows, nameField, valueField }: { rows: Record<string, unknown>
187198
<Cell key={i} fill={COLORS[i % COLORS.length]} />
188199
))}
189200
</Pie>
190-
<Tooltip formatter={(v: number) => [fmtNum(v)]} />
201+
<Tooltip formatter={(value: number | string) => [formatChartMetricValue(value, valueField, rows), fieldToLabel(valueField)]} />
191202
<Legend iconSize={10} wrapperStyle={{ fontSize: 11 }} />
192203
</PieChart>
193204
</ResponsiveContainer>

packages/server/src/modules/ai/ai-sql.service.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,11 +106,31 @@ export class AiSqlService {
106106
nextSql = nextSql.replace(/join\s+customer\b/gi, 'LEFT JOIN customer');
107107
}
108108

109+
nextSql = this.rewriteCreatedAtToChinaTime(nextSql);
110+
109111
return nextSql
110112
.replace(/\s+LIMIT\s+100\s+LIMIT\s+100/gi, ' LIMIT 100')
111113
.replace(/ORDER\s+BY\s+created_at\s+DESC\s+LIMIT\s+(\d+)\s+LIMIT\s+\1/gi, 'ORDER BY created_at DESC LIMIT $1')
112114
}
113115

116+
private rewriteCreatedAtToChinaTime(sql: string) {
117+
let nextSql = sql;
118+
119+
// 数据库里的 created_at 目前按 UTC 存储,AI 常用 DATE(created_at)=DATE('now','localtime')
120+
// 会把北京时间凌晨的订单误判成前一天,因此统一把 created_at 比较/展示口径转成北京时间。
121+
nextSql = nextSql.replace(
122+
/DATE\(\s*((?!datetime\()[^()]*?\bcreated_at\b[^()]*)\s*\)/gi,
123+
(_matched, columnExpr: string) => `DATE(datetime(${columnExpr.trim()}, '+8 hours'))`,
124+
);
125+
126+
nextSql = nextSql.replace(
127+
/strftime\(\s*'(%Y-%m(?:-%d)?)'\s*,\s*((?!datetime\()[^()]*?\bcreated_at\b[^()]*)\s*\)/gi,
128+
(_matched, format: string, columnExpr: string) => `strftime('${format}', datetime(${columnExpr.trim()}, '+8 hours'))`,
129+
);
130+
131+
return nextSql;
132+
}
133+
114134
private normalizeRowDateTimes(row: Record<string, unknown>) {
115135
return Object.fromEntries(
116136
Object.entries(row).map(([key, value]) => [key, this.normalizeDateTimeValue(key, value)]),

packages/server/src/modules/ai/ai.service.ts

Lines changed: 28 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -575,7 +575,7 @@ export class AiService implements OnModuleInit, OnModuleDestroy {
575575
const emit = (event: string, data: unknown) => emitter?.(event, data);
576576
// ── 1. 并行获取上下文 + AI 配置 ─────────────────────────────────────────
577577
const [structuredContext, availability] = await Promise.all([
578-
useRecentStructuredContext ? this.getRecentStructuredContext(user.sub) : Promise.resolve({}),
578+
useRecentStructuredContext ? this.getRecentStructuredContext(user.sub, currentSessionId) : Promise.resolve({}),
579579
this.aiConfigService.getAvailability(),
580580
]);
581581

@@ -2643,36 +2643,39 @@ export class AiService implements OnModuleInit, OnModuleDestroy {
26432643
return '我刚刚没有成功查到这条数据。请换一种更具体的问法再试一次,比如带上订单号、客户名、供应商名或时间范围。';
26442644
}
26452645

2646-
private async getRecentStructuredContext(userId: number): Promise<AiStructuredContext> {
2646+
private async getRecentStructuredContext(userId: number, sessionId?: string): Promise<AiStructuredContext> {
2647+
if (!sessionId) {
2648+
return {};
2649+
}
2650+
26472651
const conversations = await this.aiConversationRepository.find({
2648-
where: { userId },
2652+
where: { userId, sessionId },
26492653
order: { id: 'DESC' },
26502654
take: 6,
26512655
});
26522656

2653-
return conversations.reduce<AiStructuredContext>((merged, conversation) => {
2654-
if (!conversation.contextJson) {
2655-
return merged;
2656-
}
2657+
const latestConversationWithContext = conversations.find((conversation) => Boolean(conversation.contextJson?.trim()));
2658+
if (!latestConversationWithContext?.contextJson) {
2659+
return {};
2660+
}
26572661

2658-
try {
2659-
const context = JSON.parse(conversation.contextJson) as AiStructuredContext;
2660-
return {
2661-
orderNos: this.mergeContextList(merged.orderNos, context.orderNos),
2662-
returnNos: this.mergeContextList(merged.returnNos, context.returnNos),
2663-
refundNos: this.mergeContextList(merged.refundNos, context.refundNos),
2664-
exchangeNos: this.mergeContextList(merged.exchangeNos, context.exchangeNos),
2665-
customerNames: this.mergeContextList(merged.customerNames, context.customerNames),
2666-
customerContacts: this.mergeContextList(merged.customerContacts, context.customerContacts),
2667-
customerPhones: this.mergeContextList(merged.customerPhones, context.customerPhones),
2668-
supplierNames: this.mergeContextList(merged.supplierNames, context.supplierNames),
2669-
productNames: this.mergeContextList(merged.productNames, context.productNames),
2670-
reasonCodes: this.mergeContextList(merged.reasonCodes, context.reasonCodes),
2671-
};
2672-
} catch {
2673-
return merged;
2674-
}
2675-
}, {});
2662+
try {
2663+
const context = JSON.parse(latestConversationWithContext.contextJson) as AiStructuredContext;
2664+
return {
2665+
orderNos: context.orderNos?.slice(0, 6),
2666+
returnNos: context.returnNos?.slice(0, 6),
2667+
refundNos: context.refundNos?.slice(0, 6),
2668+
exchangeNos: context.exchangeNos?.slice(0, 6),
2669+
customerNames: context.customerNames?.slice(0, 6),
2670+
customerContacts: context.customerContacts?.slice(0, 6),
2671+
customerPhones: context.customerPhones?.slice(0, 6),
2672+
supplierNames: context.supplierNames?.slice(0, 6),
2673+
productNames: context.productNames?.slice(0, 6),
2674+
reasonCodes: context.reasonCodes?.slice(0, 6),
2675+
};
2676+
} catch {
2677+
return {};
2678+
}
26762679
}
26772680

26782681
private mergeContextList(current: string[] | undefined, next: string[] | undefined) {

0 commit comments

Comments
 (0)