「大于 1000」把 1000 元那单也算进去了:我在 Vue3 订单页对了 10 句话

运营丢来一句「上周上海未支付,金额大于 1000」。旧筛选把正好 1000 元的 A1001 留在表格里。改完之后,大于 1000 只剩 A1002、A1010;说成大于等于,A1001 才出现。基准日 2026-10-10,上周锁定为 9 月 28 日到 10 月 4 日。本地 10 条用例 10/10。

前言

我们后台的订单页是 Vue3 + Element Plus。筛选一直是五个控件:城市、状态、最小金额、最大金额、日期。运营不爱填,习惯在微信里丢一句人话。

我加过一版「生成筛选」。模型返回 JSON,页面直接铺进表单。演示的时候挺顺,拿真实句子去对单,三个错叠在一起:1000 元那单不该在,上周和最近 7 天被算成同一段,请求参数里还出现了模型自己加的 sql。

这篇按对单的顺序写。先给 10 张订单和改之前的结果,再规定大于、上周、城市别名怎么收,然后是能跑的代码、10 条脏数据,以及 10 句要打到模型上的人话。

一、10 张订单,改之前错在哪

基准日定在 2026-10-10,周六。页面上的日期框可以改,文里的对单结果都锁在这一天,不然「上周」每天都会变,用例对不上。

单号 城市 金额 状态 日期 备注
A1001 上海 1000 未支付 2026-09-29 黄底,专门卡边界
A1002 上海 1280 未支付 2026-09-30
A1003 上海 860 未支付 2026-10-04 落在上周的最后一天
A1004 北京 2200 未支付 2026-10-02
A1005 上海 1688 已支付 2026-09-28 金额够,状态不对
A1006 杭州 980 已支付 2026-10-08
A1007 深圳 640 已关闭 2026-10-06
A1008 北京 800 已关闭 2026-10-08
A1009 广州 1500 已支付 2026-10-03
A1010 上海 2560 未支付 2026-10-03

句子是:「上周上海未支付,金额大于 1000」。

旧逻辑把「大于」写成 amount >= 1000,又让模型自己填日期。模型有时写成 10 月 3 日到 10 月 9 日,有时把「上海市」原样塞进下拉框。下拉框的值是「上海」,对不上就空,空又被当成「不限城市」。A1004 这种北京单会漏进来。

按后来定死的规则,这句只该命中两张单。

单号 大于 1000 原因
A1001 不中 正好 1000,大于不含
A1002 中 上海、未支付、1280、在上周内
A1003 不中 日期在上周,金额 860 不够
A1004 不中 北京
A1005 不中 已支付
A1010 中 上海、未支付、2560、10 月 3 日

同一句改成「大于等于 1000」,只多出 A1001。我就用这一张黄底单判断代码有没有把大于写成大于等于。

二、上周和最近 7 天不是同一段

2026 年 10 月 10 日是周六。周从周一起算:

text 复制代码
        一      二      三      四      五      六      日
上周   09-28   09-29   09-30   10-01   10-02   10-03   10-04
本周   10-05   10-06   10-07   10-08   10-09   10-10   10-11

最近 7 天含基准日当天,往前数 6 天:10 月 4 日到 10 月 10 日。

10 月 4 日卡在两边。它是上周日,也是最近 7 天的第一天。A1003 就在这一天。只看「有没有这张单」分不清两段日期,还得看金额和另外几天。所以上周的用例期望写成完整起止日期,不写成「大概是上周」。

周日的 getDay() 是 0。若写成「减一天就是周一」,周日会跳到周六,整周错位。代码里周日退 6 天。

本周是 10 月 5 日到 10 月 11 日,周日在基准日后面,表格里可以没有单,日期仍然要铺满这一周。本月是 10 月 1 日到 10 月 31 日。

这些日期由前端算。模型只许返回 last_week、last_7_days、this_week、this_month、absolute、none。它若在 last_week 旁边又写了 10 月 3 日到 10 月 9 日,那段日期丢弃,警告里写「已忽略模型自算的日期」。

三、城市、状态、金额各自怎么收

运营怎么说 收成
上海市、沪 上海
北京市、京 北京
广州市、深圳市、杭州市 去掉「市」
成都 城市清空,页面警告,别的条件保留
未支付、未付款、待支付、待付款 unpaid
已支付、已付款、支付成功 paid
已关闭、已取消、取消 closed
退款中 状态回到全部,并警告
大于 1000 minAmount=1000,minInclusive=false
大于等于、不低于、以上 minInclusive=true
小于 1000 maxInclusive=false
不超过、以下 maxInclusive=true
500 到 2000 两侧都含
大于 1000 且小于等于 3000 左侧不含,右侧含

「已取消」并进 closed 是我们订单状态只有三种的决定。别的项目有单独取消状态,改别名表,同时改第 8 条和第 10 条用例。

金额必须是对象,而且带布尔值 inclusive:

json 复制代码
{ "min": { "value": 1000, "inclusive": false }, "max": null }

只有数字、没有 inclusive,这一侧扔掉。一个 op: "gte" 也扔掉。大于 1000 且小于等于 3000 无法塞进一个 op,所以两侧分开存。

城市按整词匹配。「上海周边」不会收成上海,避免短别名误伤。

发给接口的参数只留这些键:city、status、minAmount、minInclusive、maxAmount、maxInclusive、startDate、endDate。没有城市就不传 city,状态是全部就不传 status。模型附带的 sql、顶层裸的 minAmount,都进警告,不进参数。

大于 1000、上周、上海、未支付,最终参数是:

json 复制代码
{
  "city": "上海",
  "status": "unpaid",
  "minAmount": 1000,
  "minInclusive": false,
  "startDate": "2026-09-28",
  "endDate": "2026-10-04"
}

这里没有 maxAmount,也没有 sql。

四、收口函数

新建 src/filters/interpret.ts。这个文件不引用 Vue。本地 10 条直接喂脏 JSON,不请求模型。

ts 复制代码
export const BASELINE_DAY = '2026-10-10'

export const QUERY_KEYS = [
  'city',
  'status',
  'minAmount',
  'minInclusive',
  'maxAmount',
  'maxInclusive',
  'startDate',
  'endDate',
] as const

export const ORDER_STATUSES = ['all', 'unpaid', 'paid', 'closed'] as const
export type OrderStatus = (typeof ORDER_STATUSES)[number]

export interface OrderFilter {
  city: string
  status: OrderStatus
  minAmount: number | null
  minInclusive: boolean
  maxAmount: number | null
  maxInclusive: boolean
  startDate: string
  endDate: string
}

export interface Order {
  id: string
  city: string
  amount: number
  status: Exclude<OrderStatus, 'all'>
  date: string
}

export interface InterpretResult {
  filter: OrderFilter
  warnings: string[]
  query: Record<string, string | number | boolean>
}

interface AmountBound {
  value: number
  inclusive: boolean
}

const CITY_ALIASES: Record<string, string> = {
  北京: '北京',
  北京市: '北京',
  京: '北京',
  上海: '上海',
  上海市: '上海',
  沪: '上海',
  广州: '广州',
  广州市: '广州',
  深圳: '深圳',
  深圳市: '深圳',
  杭州: '杭州',
  杭州市: '杭州',
}

const STATUS_ALIASES: Record<string, OrderStatus> = {
  all: 'all',
  全部: 'all',
  所有: 'all',
  不限: 'all',
  unpaid: 'unpaid',
  未支付: 'unpaid',
  未付款: 'unpaid',
  待支付: 'unpaid',
  待付款: 'unpaid',
  paid: 'paid',
  已支付: 'paid',
  已付款: 'paid',
  支付成功: 'paid',
  closed: 'closed',
  已关闭: 'closed',
  已取消: 'closed',
  取消: 'closed',
}

const ALLOWED_FIELDS = new Set(['city', 'status', 'amount', 'datePreset', 'startDate', 'endDate'])

export function emptyFilter(): OrderFilter {
  return {
    city: '',
    status: 'all',
    minAmount: null,
    minInclusive: true,
    maxAmount: null,
    maxInclusive: true,
    startDate: '',
    endDate: '',
  }
}

export function expectFilter(partial: Partial<OrderFilter>): OrderFilter {
  return { ...emptyFilter(), ...partial }
}

function parseISODate(value: string) {
  const [year, month, day] = value.split('-').map(Number)
  return new Date(year, month - 1, day)
}

function formatDate(date: Date) {
  const pad = (value: number) => String(value).padStart(2, '0')
  return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`
}

function addDays(date: Date, days: number) {
  const next = new Date(date.getFullYear(), date.getMonth(), date.getDate())
  next.setDate(next.getDate() + days)
  return next
}

function startOfWeekMonday(date: Date) {
  const day = date.getDay()
  const delta = day === 0 ? -6 : 1 - day
  return addDays(date, delta)
}

function isISODate(value: string) {
  if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return false
  return formatDate(parseISODate(value)) === value
}

function asNumber(value: unknown) {
  if (typeof value === 'number' && Number.isFinite(value)) return value
  if (typeof value === 'string' && value.trim() && Number.isFinite(Number(value))) return Number(value)
  return null
}

function resolvePreset(preset: string, today: Date) {
  if (preset === 'last_week') {
    const start = addDays(startOfWeekMonday(today), -7)
    return { start: formatDate(start), end: formatDate(addDays(start, 6)) }
  }
  if (preset === 'last_7_days') {
    return { start: formatDate(addDays(today, -6)), end: formatDate(today) }
  }
  if (preset === 'this_week') {
    const start = startOfWeekMonday(today)
    return { start: formatDate(start), end: formatDate(addDays(start, 6)) }
  }
  if (preset === 'this_month') {
    const start = new Date(today.getFullYear(), today.getMonth(), 1)
    const end = new Date(today.getFullYear(), today.getMonth() + 1, 0)
    return { start: formatDate(start), end: formatDate(end) }
  }
  return null
}

function applyCity(value: unknown, filter: OrderFilter, warnings: string[]) {
  if (value === '' || value === null || value === undefined) return
  if (typeof value !== 'string') {
    warnings.push('城市不是字符串,已忽略')
    return
  }
  const text = value.trim()
  if (!text) return
  const canonical = CITY_ALIASES[text]
  if (!canonical) {
    warnings.push(`城市「${text}」不在可筛范围,已忽略`)
    return
  }
  filter.city = canonical
  if (text !== canonical) warnings.push(`城市已从「${text}」收成「${canonical}」`)
}

function applyStatus(value: unknown, filter: OrderFilter, warnings: string[]) {
  if (value === '' || value === null || value === undefined) return
  if (typeof value !== 'string') {
    warnings.push('状态不是字符串,已按全部处理')
    return
  }
  const text = value.trim()
  if (!text) return
  const canonical = STATUS_ALIASES[text]
  if (!canonical) {
    warnings.push(`状态「${text}」不在可筛范围,已按全部处理`)
    return
  }
  filter.status = canonical
  if (text !== canonical) warnings.push(`状态已从「${text}」收成「${canonical}」`)
}

function readBound(value: unknown, warnings: string[], side: string): AmountBound | null {
  if (value === null || value === undefined || value === '') return null
  if (typeof value !== 'object' || Array.isArray(value)) {
    warnings.push(`${side}只有数字没有 inclusive,已忽略`)
    return null
  }
  const bound = value as Record<string, unknown>
  const number = asNumber(bound.value)
  if (number === null || typeof bound.inclusive !== 'boolean') {
    warnings.push(`${side}缺少 value 或 inclusive,已忽略`)
    return null
  }
  return { value: number, inclusive: bound.inclusive }
}

function applyAmount(value: unknown, filter: OrderFilter, warnings: string[]) {
  if (value === undefined || value === null) return
  if (typeof value !== 'object' || Array.isArray(value)) {
    warnings.push('金额不是对象,已忽略')
    return
  }
  const amount = value as Record<string, unknown>
  for (const key of Object.keys(amount)) {
    if (key !== 'min' && key !== 'max') warnings.push(`已丢弃金额字段 ${key}`)
  }
  const min = readBound(amount.min, warnings, '最小金额')
  const max = readBound(amount.max, warnings, '最大金额')
  if (min && max && min.value > max.value) {
    warnings.push('最小金额大于最大金额,金额条件已忽略')
    return
  }
  if (min) {
    filter.minAmount = min.value
    filter.minInclusive = min.inclusive
  }
  if (max) {
    filter.maxAmount = max.value
    filter.maxInclusive = max.inclusive
  }
}

function applyDate(data: Record<string, unknown>, filter: OrderFilter, warnings: string[], today: Date) {
  const preset = typeof data.datePreset === 'string' ? data.datePreset : 'none'
  const relative = resolvePreset(preset, today)
  if (relative) {
    filter.startDate = relative.start
    filter.endDate = relative.end
    if (data.startDate || data.endDate) warnings.push('已忽略模型自算的日期')
    return
  }
  if (preset !== 'none' && preset !== 'absolute' && preset !== '') {
    warnings.push(`日期预设「${preset}」无法识别,已忽略`)
    return
  }
  const start = typeof data.startDate === 'string' ? data.startDate : ''
  const end = typeof data.endDate === 'string' ? data.endDate : ''
  if (!start && !end) return
  if (!isISODate(start) || !isISODate(end) || start > end) {
    warnings.push('日期格式无法识别,已忽略')
    return
  }
  filter.startDate = start
  filter.endDate = end
}

export function toQuery(filter: OrderFilter) {
  const query: Record<string, string | number | boolean> = {}
  if (filter.city) query.city = filter.city
  if (filter.status !== 'all') query.status = filter.status
  if (filter.minAmount !== null) {
    query.minAmount = filter.minAmount
    query.minInclusive = filter.minInclusive
  }
  if (filter.maxAmount !== null) {
    query.maxAmount = filter.maxAmount
    query.maxInclusive = filter.maxInclusive
  }
  if (filter.startDate) query.startDate = filter.startDate
  if (filter.endDate) query.endDate = filter.endDate
  return query
}

export function interpret(raw: unknown, todayInput: string): InterpretResult {
  const warnings: string[] = []
  const filter = emptyFilter()
  const today = parseISODate(todayInput)
  if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
    warnings.push('模型没有返回对象,筛选保持为空')
    return { filter, warnings, query: toQuery(filter) }
  }
  const data = raw as Record<string, unknown>
  for (const key of Object.keys(data)) {
    if (!ALLOWED_FIELDS.has(key)) warnings.push(`已丢弃字段 ${key}`)
  }
  applyCity(data.city, filter, warnings)
  applyStatus(data.status, filter, warnings)
  applyAmount(data.amount, filter, warnings)
  applyDate(data, filter, warnings, today)
  return { filter, warnings, query: toQuery(filter) }
}

export function sameFilter(left: OrderFilter, right: OrderFilter) {
  const sameBound = (
    amount: number | null,
    inclusive: boolean,
    otherAmount: number | null,
    otherInclusive: boolean,
  ) => {
    if (amount === null && otherAmount === null) return true
    return amount === otherAmount && inclusive === otherInclusive
  }
  return (
    left.city === right.city &&
    left.status === right.status &&
    left.startDate === right.startDate &&
    left.endDate === right.endDate &&
    sameBound(left.minAmount, left.minInclusive, right.minAmount, right.minInclusive) &&
    sameBound(left.maxAmount, left.maxInclusive, right.maxAmount, right.maxInclusive)
  )
}

export function matchOrder(order: Order, filter: OrderFilter) {
  if (filter.city && order.city !== filter.city) return false
  if (filter.status !== 'all' && order.status !== filter.status) return false
  if (filter.minAmount !== null) {
    const pass = filter.minInclusive ? order.amount >= filter.minAmount : order.amount > filter.minAmount
    if (!pass) return false
  }
  if (filter.maxAmount !== null) {
    const pass = filter.maxInclusive ? order.amount <= filter.maxAmount : order.amount < filter.maxAmount
    if (!pass) return false
  }
  if (filter.startDate && order.date < filter.startDate) return false
  if (filter.endDate && order.date > filter.endDate) return false
  return true
}

export function amountLabel(filter: OrderFilter) {
  const parts: string[] = []
  if (filter.minAmount !== null) parts.push(`${filter.minInclusive ? '≥' : '>'} ${filter.minAmount}`)
  if (filter.maxAmount !== null) parts.push(`${filter.maxInclusive ? '≤' : '<'} ${filter.maxAmount}`)
  return parts.length ? parts.join(' 且 ') : '不限'
}

2026-02-31 会被 isISODate 拒绝。Date 会把这一天进位到 3 月,格式化之后对不上原字符串,整段日期丢掉,不会留下「只有开始、没有结束」。斜杠日期同样整段丢掉,金额条件可以单独留下。

sameFilter 在两侧金额都是 null 时,不再比较 inclusive。空筛选的默认值是 true,和「这一侧不存在」是一回事。

五、10 条脏数据,每条锁一个错

这 10 条不调用模型。输入是我在对单时见过的脏 JSON,外加几个故意加错的字段。改别名、改周一、改大于含不含,先跑 runLocalCases()。

继续写在 interpret.ts 底部:

ts 复制代码
interface LocalCase {
  name: string
  input: unknown
  expected: OrderFilter
  warningIncludes: string[]
  queryExcludes?: string[]
}

export const LOCAL_CASES: LocalCase[] = [
  {
    name: '大于不含边界,上周按周一,丢掉 sql',
    input: {
      city: '上海市',
      status: '未支付',
      amount: { min: { value: 1000, inclusive: false }, max: null },
      datePreset: 'last_week',
      startDate: '2026-10-03',
      endDate: '2026-10-09',
      sql: 'drop table orders',
    },
    expected: expectFilter({
      city: '上海',
      status: 'unpaid',
      minAmount: 1000,
      minInclusive: false,
      startDate: '2026-09-28',
      endDate: '2026-10-04',
    }),
    warningIncludes: ['上海市', '已忽略模型自算的日期', 'sql'],
    queryExcludes: ['sql'],
  },
  {
    name: '大于等于含边界,不认 op',
    input: {
      city: '上海',
      status: 'unpaid',
      amount: { op: 'gte', min: { value: 1000, inclusive: true }, max: null },
      datePreset: 'none',
    },
    expected: expectFilter({
      city: '上海',
      status: 'unpaid',
      minAmount: 1000,
      minInclusive: true,
    }),
    warningIncludes: ['op'],
  },
  {
    name: '区间两端包含,最近7天',
    input: {
      city: '杭州',
      status: '已支付',
      amount: {
        min: { value: 500, inclusive: true },
        max: { value: 2000, inclusive: true },
      },
      datePreset: 'last_7_days',
    },
    expected: expectFilter({
      city: '杭州',
      status: 'paid',
      minAmount: 500,
      minInclusive: true,
      maxAmount: 2000,
      maxInclusive: true,
      startDate: '2026-10-04',
      endDate: '2026-10-10',
    }),
    warningIncludes: ['已支付'],
  },
  {
    name: '城市不在白名单',
    input: { city: '成都', status: '', amount: { min: null, max: null }, datePreset: 'this_month' },
    expected: expectFilter({ startDate: '2026-10-01', endDate: '2026-10-31' }),
    warningIncludes: ['成都'],
  },
  {
    name: '状态不在白名单',
    input: { city: '深圳', status: '退款中', datePreset: 'none' },
    expected: expectFilter({ city: '深圳', status: 'all' }),
    warningIncludes: ['退款中'],
  },
  {
    name: '最近7天不是上周',
    input: { city: '', status: '全部', datePreset: 'last_7_days' },
    expected: expectFilter({ startDate: '2026-10-04', endDate: '2026-10-10' }),
    warningIncludes: ['全部'],
  },
  {
    name: '斜杠日期整段丢掉',
    input: {
      city: '北京',
      status: '已关闭',
      amount: { max: { value: 1000, inclusive: false }, min: null },
      datePreset: 'absolute',
      startDate: '2026/10/01',
      endDate: '2026-10-10',
    },
    expected: expectFilter({
      city: '北京',
      status: 'closed',
      maxAmount: 1000,
      maxInclusive: false,
    }),
    warningIncludes: ['日期格式无法识别'],
  },
  {
    name: '已取消并进关闭,裸金额丢掉',
    input: { city: '广州', status: '已取消', minAmount: 1000, datePreset: 'none' },
    expected: expectFilter({ city: '广州', status: 'closed' }),
    warningIncludes: ['minAmount', '已取消'],
  },
  {
    name: '沪收成上海,不超过含边界,本月',
    input: {
      city: '沪',
      status: '已付款',
      amount: { max: { value: 2000, inclusive: true }, min: null },
      datePreset: 'this_month',
    },
    expected: expectFilter({
      city: '上海',
      status: 'paid',
      maxAmount: 2000,
      maxInclusive: true,
      startDate: '2026-10-01',
      endDate: '2026-10-31',
    }),
    warningIncludes: ['沪'],
  },
  {
    name: '两侧边界不同,绝对日期',
    input: {
      city: '广州',
      status: '已取消',
      amount: {
        min: { value: 1000, inclusive: false },
        max: { value: 3000, inclusive: true },
      },
      datePreset: 'absolute',
      startDate: '2026-10-01',
      endDate: '2026-10-10',
    },
    expected: expectFilter({
      city: '广州',
      status: 'closed',
      minAmount: 1000,
      minInclusive: false,
      maxAmount: 3000,
      maxInclusive: true,
      startDate: '2026-10-01',
      endDate: '2026-10-10',
    }),
    warningIncludes: ['已取消'],
  },
]

export function runLocalCases(today = BASELINE_DAY) {
  return LOCAL_CASES.map((item) => {
    const result = interpret(item.input, today)
    const keyOk = Object.keys(result.query).every((key) =>
      (QUERY_KEYS as readonly string[]).includes(key),
    )
    const excludedOk = (item.queryExcludes ?? []).every((key) => !(key in result.query))
    const warningOk = item.warningIncludes.every((text) =>
      result.warnings.some((warning) => warning.includes(text)),
    )
    return {
      name: item.name,
      ok: sameFilter(result.filter, item.expected) && keyOk && excludedOk && warningOk,
      warnings: result.warnings,
      query: result.query,
    }
  })
}

逐条对一下表格,避免用例和故事各说各的。

第 1 条。 输入把城市写成上海市,状态写成未支付,日期预设是上周,同时又塞了 10 月 3 日到 10 月 9 日和一段 sql。收成上海、unpaid、不含 1000、9 月 28 日到 10 月 4 日。警告里要同时出现「上海市」「已忽略模型自算的日期」「sql」。参数对象里不能有 sql。用这张筛选去套 10 张订单,命中 A1002、A1010。

第 2 条。 金额对象里故意留了 op: "gte"。判断只看 inclusive: true,所以 1000 含在里面。警告里要出现 op。这张没有日期,上海未支付且金额大于等于 1000 的单是 A1001、A1002、A1010。A1001 就是和第 1 条的差别。

第 3 条。 杭州、已支付、500 到 2000 两端包含、最近 7 天。日期必须是 10 月 4 日到 10 月 10 日,不能是上周。A1006 是杭州、已支付、980、10 月 8 日,会中。A1009 是广州,不会中。

第 4 条。 成都不在城市表里。城市清空,本月 10 月 1 日到 10 月 31 日保留。不能因为城市失败把日期也清掉,运营还要看到「月份是对的,城市没接住」。

第 5 条。 「退款中」没有别名。深圳留下,状态回到全部。若把未知状态直接写进下拉框,el-select 会显示一个不在选项里的值,下次手改也容易乱。

第 6 条。 只给「全部 + 最近 7 天」。日期锁死 10 月 4 日到 10 月 10 日。这条专门防止有人把两个预设收成同一个函数。

第 7 条。 开始日期是 2026/10/01,结束日期格式是对的。仍然整段丢掉,北京和「小于 1000」保留。半截日期比没有日期更危险,会把 10 月以前的单全部卷进来。

第 8 条。 「已取消」收成 closed。minAmount: 1000 写在顶层,不在 amount 里,必须丢掉。我第一版见过模型省事,把数字写在外面,代码如果顺手读了,大于和大于等于又分不清。

第 9 条。 「沪」收成上海,「已付款」收成 paid,「不超过 2000」含边界,本月。A1005 是上海已支付 1688,日期 9 月 28 日,不在本月,不会中。用来确认「不超过」没有把日期条件冲掉。

第 10 条。 大于 1000 且小于等于 3000,绝对日期 10 月 1 日到 10 月 10 日,已取消并进 closed。两侧边界必须同时在。A1009 是广州、已支付、1500、10 月 3 日,状态对不上,不会中。

10 条全绿之后,再用第 1 条和第 2 条去筛那 10 张订单。大于命中 2 张,大于等于命中 3 张。这一步我写在页面加载时,打开就能看见 10/10。

六、10 句人话,专门拿来打模型

本地用例证明收口函数没坏。模型会不会把人话拆成这个结构,是另一件事。期望和本地用例共用 expectFilter,避免两套日期。

ts 复制代码
export const SENTENCE_CASES: Array<{ sentence: string; expected: OrderFilter }> = [
  {
    sentence: '上周上海未支付、金额大于 1000 的订单',
    expected: expectFilter({
      city: '上海',
      status: 'unpaid',
      minAmount: 1000,
      minInclusive: false,
      startDate: '2026-09-28',
      endDate: '2026-10-04',
    }),
  },
  {
    sentence: '上周上海未支付、金额大于等于 1000 的订单',
    expected: expectFilter({
      city: '上海',
      status: 'unpaid',
      minAmount: 1000,
      minInclusive: true,
      startDate: '2026-09-28',
      endDate: '2026-10-04',
    }),
  },
  {
    sentence: '最近 7 天杭州已支付、金额 500 到 2000',
    expected: expectFilter({
      city: '杭州',
      status: 'paid',
      minAmount: 500,
      minInclusive: true,
      maxAmount: 2000,
      maxInclusive: true,
      startDate: '2026-10-04',
      endDate: '2026-10-10',
    }),
  },
  {
    sentence: '本月成都的订单',
    expected: expectFilter({ startDate: '2026-10-01', endDate: '2026-10-31' }),
  },
  {
    sentence: '退款中的深圳订单',
    expected: expectFilter({ city: '深圳' }),
  },
  {
    sentence: '10 月 8 日北京已关闭、金额小于 1000',
    expected: expectFilter({
      city: '北京',
      status: 'closed',
      maxAmount: 1000,
      maxInclusive: false,
      startDate: '2026-10-08',
      endDate: '2026-10-08',
    }),
  },
  {
    sentence: '上海市待付款、1000 以上',
    expected: expectFilter({
      city: '上海',
      status: 'unpaid',
      minAmount: 1000,
      minInclusive: true,
    }),
  },
  {
    sentence: '本周全部订单',
    expected: expectFilter({ startDate: '2026-10-05', endDate: '2026-10-11' }),
  },
  {
    sentence: '沪,已付款,不超过 2000,上周',
    expected: expectFilter({
      city: '上海',
      status: 'paid',
      maxAmount: 2000,
      maxInclusive: true,
      startDate: '2026-09-28',
      endDate: '2026-10-04',
    }),
  },
  {
    sentence: '广州 2026 年 10 月 1 日到 10 月 10 日,金额大于 1000 且小于等于 3000',
    expected: expectFilter({
      city: '广州',
      minAmount: 1000,
      minInclusive: false,
      maxAmount: 3000,
      maxInclusive: true,
      startDate: '2026-10-01',
      endDate: '2026-10-10',
    }),
  },
]

export function systemPrompt(today: string) {
  return [
    '你是订单筛选解析器。只输出一个 JSON 对象,不要 Markdown,不要解释。',
    '字段只允许 city、status、amount、datePreset、startDate、endDate。',
    'city、status 按用户原文摘出,不要自己删「市」,也不要翻译成英文。没有就返回空字符串。',
    'amount 形如 {"min":{"value":1000,"inclusive":false},"max":null}。',
    '大于:min.inclusive 为 false。大于等于、不低于、以上:min.inclusive 为 true。',
    '小于:max.inclusive 为 false。小于等于、不超过、以下:max.inclusive 为 true。',
    '「到」「至」两侧 inclusive 都为 true。没有的一侧写 null。',
    'inclusive 必须是布尔值。不要输出 op 字段。',
    '不要自己计算日历。datePreset 只能是 last_week、last_7_days、this_week、this_month、absolute、none。',
    '上周用 last_week,最近 7 天用 last_7_days,本周用 this_week,本月用 this_month。',
    '用户给出具体日期时用 absolute,startDate 和 endDate 用 YYYY-MM-DD。',
    `没写年份时按 ${today.slice(0, 4)} 年。基准日是 ${today}。`,
    '相对日期时 startDate 和 endDate 返回空字符串。',
  ].join('\n')
}

提示词要求模型保留「上海市」,由别名表收成上海。这样失败时能看出是提示词把城市翻译乱了,还是别名表没收住。基准日写进提示词,和 interpret 用同一个字符串。

「1000 以上」我按大于等于。业务若要按大于,改提示词里那一句,同时把第 7 句的 minInclusive 改成 false。只改一边,按钮打出来就是红的。

打模型时基准日仍固定 2026-10-10。手输那一句可以用日期框里的基准日,10 句回归不用,否则期望日期每天都要改。

七、请求一次,只要 JSON

新建 src/composables/useAiFilter.ts。stream 固定 false。返回正文之后仍然进 interpret。

ts 复制代码
import { ref } from 'vue'
import { interpret, systemPrompt, type InterpretResult } from '../filters/interpret'

function extractJson(text: string): unknown {
  const fenced = text.match(/```(?:json)?\s*([\s\S]*?)```/)
  return JSON.parse((fenced?.[1] ?? text).trim())
}

export function useAiFilter() {
  const loading = ref(false)
  const error = ref('')

  async function parseSentence(sentence: string, today: string): Promise<InterpretResult & { error: string }> {
    const empty = interpret({}, today)
    const text = sentence.trim()
    if (!text) return { ...empty, error: '' }

    loading.value = true
    error.value = ''
    try {
      const response = await fetch('/ai/chat/completions', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          model: import.meta.env.VITE_AI_MODEL || 'deepseek-chat',
          temperature: 0,
          stream: false,
          response_format: { type: 'json_object' },
          messages: [
            { role: 'system', content: systemPrompt(today) },
            { role: 'user', content: text },
          ],
        }),
      })
      if (!response.ok) throw new Error(`请求失败:${response.status}`)
      const payload = await response.json()
      const content = payload.choices?.[0]?.message?.content
      if (typeof content !== 'string') throw new Error('模型没有返回内容')
      return { ...interpret(extractJson(content), today), error: '' }
    } catch (err) {
      const message = err instanceof Error ? err.message : '解析失败'
      error.value = message
      return { ...empty, error: message }
    } finally {
      loading.value = false
    }
  }

  return { loading, error, parseSentence }
}

temperature 设为 0,同一句话两次点,结构尽量不变。模型若把 JSON 包在 Markdown 代码块里,extractJson 先剥掉再解析。解析失败就保持空筛选,页面上留错误文案,不把半截字符串写进表单。

八、订单页

新建 src/views/OrderFilter.vue。main.ts 里如果已经 app.use(ElementPlus),下面的组件可以直接用。

黄底行是金额正好 1000 的单。生成之后,警告、金额说法、请求 JSON、命中行数放在同一张卡片里,对单时不用翻控制台。

html 复制代码
<script setup lang="ts">
import { computed, reactive, ref } from 'vue'
import {
  BASELINE_DAY,
  SENTENCE_CASES,
  amountLabel,
  emptyFilter,
  matchOrder,
  runLocalCases,
  sameFilter,
  type Order,
  type OrderFilter,
} from '../filters/interpret'
import { useAiFilter } from '../composables/useAiFilter'

const sentence = ref('上周上海未支付、金额大于 1000 的订单')
const baseline = ref(BASELINE_DAY)
const query = reactive<OrderFilter>(emptyFilter())
const warnings = ref<string[]>([])
const queryText = ref('{}')
const localReport = runLocalCases()
const localPassed = localReport.filter((item) => item.ok).length
const modelReport = ref<Array<{ name: string; ok: boolean; detail: string }>>([])
const { loading, error, parseSentence } = useAiFilter()

const orders: Order[] = [
  { id: 'A1001', city: '上海', amount: 1000, status: 'unpaid', date: '2026-09-29' },
  { id: 'A1002', city: '上海', amount: 1280, status: 'unpaid', date: '2026-09-30' },
  { id: 'A1003', city: '上海', amount: 860, status: 'unpaid', date: '2026-10-04' },
  { id: 'A1004', city: '北京', amount: 2200, status: 'unpaid', date: '2026-10-02' },
  { id: 'A1005', city: '上海', amount: 1688, status: 'paid', date: '2026-09-28' },
  { id: 'A1006', city: '杭州', amount: 980, status: 'paid', date: '2026-10-08' },
  { id: 'A1007', city: '深圳', amount: 640, status: 'closed', date: '2026-10-06' },
  { id: 'A1008', city: '北京', amount: 800, status: 'closed', date: '2026-10-08' },
  { id: 'A1009', city: '广州', amount: 1500, status: 'paid', date: '2026-10-03' },
  { id: 'A1010', city: '上海', amount: 2560, status: 'unpaid', date: '2026-10-03' },
]

const statusText: Record<Order['status'], string> = {
  unpaid: '未支付',
  paid: '已支付',
  closed: '已关闭',
}

const filteredOrders = computed(() => orders.filter((order) => matchOrder(order, query)))

function applyResult(filter: OrderFilter, nextWarnings: string[], nextQuery: Record<string, string | number | boolean>) {
  Object.assign(query, filter)
  warnings.value = nextWarnings
  queryText.value = JSON.stringify(nextQuery, null, 2)
}

async function applySentence() {
  const today = baseline.value || BASELINE_DAY
  const result = await parseSentence(sentence.value, today)
  applyResult(result.filter, result.warnings, result.query)
}

async function runModelCases() {
  modelReport.value = []
  for (const item of SENTENCE_CASES) {
    const result = await parseSentence(item.sentence, BASELINE_DAY)
    const ok = result.error === '' && sameFilter(result.filter, item.expected)
    modelReport.value.push({
      name: item.sentence,
      ok,
      detail: ok
        ? ''
        : result.error ||
          `${result.filter.city || '全部城市'} / ${result.filter.status} / ${amountLabel(result.filter)} / ${result.filter.startDate || '不限'}~${result.filter.endDate || '不限'}`,
    })
  }
}

function resetFilter() {
  Object.assign(query, emptyFilter())
  warnings.value = []
  queryText.value = '{}'
  sentence.value = ''
}
</script>

<template>
  <div class="page">
    <el-card shadow="never">
      <div class="sentence">
        <el-date-picker v-model="baseline" type="date" value-format="YYYY-MM-DD" placeholder="基准日" />
        <el-input v-model="sentence" clearable @keyup.enter="applySentence" />
        <el-button type="primary" :loading="loading" @click="applySentence">生成筛选</el-button>
        <el-button @click="resetFilter">重置</el-button>
      </div>
      <p v-if="error" class="error">{{ error }}</p>
      <el-alert v-for="item in warnings" :key="item" :title="item" type="warning" :closable="false" class="warn" />

      <el-form :model="query" inline class="query">
        <el-form-item label="城市">
          <el-select v-model="query.city" clearable placeholder="全部" style="width: 120px">
            <el-option label="北京" value="北京" />
            <el-option label="上海" value="上海" />
            <el-option label="广州" value="广州" />
            <el-option label="深圳" value="深圳" />
            <el-option label="杭州" value="杭州" />
          </el-select>
        </el-form-item>
        <el-form-item label="状态">
          <el-select v-model="query.status" style="width: 120px">
            <el-option label="全部" value="all" />
            <el-option label="未支付" value="unpaid" />
            <el-option label="已支付" value="paid" />
            <el-option label="已关闭" value="closed" />
          </el-select>
        </el-form-item>
        <el-form-item label="最小金额">
          <el-input-number v-model="query.minAmount" :min="0" :controls="false" :value-on-clear="null" />
          <el-checkbox v-model="query.minInclusive" :disabled="query.minAmount === null">包含等于</el-checkbox>
        </el-form-item>
        <el-form-item label="最大金额">
          <el-input-number v-model="query.maxAmount" :min="0" :controls="false" :value-on-clear="null" />
          <el-checkbox v-model="query.maxInclusive" :disabled="query.maxAmount === null">包含等于</el-checkbox>
        </el-form-item>
        <el-form-item label="开始">
          <el-date-picker v-model="query.startDate" type="date" value-format="YYYY-MM-DD" />
        </el-form-item>
        <el-form-item label="结束">
          <el-date-picker v-model="query.endDate" type="date" value-format="YYYY-MM-DD" />
        </el-form-item>
      </el-form>
      <p class="summary">金额:{{ amountLabel(query) }},当前 {{ filteredOrders.length }} 条</p>
      <pre class="query-text">{{ queryText }}</pre>
    </el-card>

    <el-table :data="filteredOrders" border class="table" :row-class-name="({ row }) => (row.amount === 1000 ? 'boundary' : '')">
      <el-table-column prop="id" label="订单号" width="120" />
      <el-table-column prop="city" label="城市" width="100" />
      <el-table-column prop="amount" label="金额" width="120" />
      <el-table-column label="状态" width="120">
        <template #default="{ row }">{{ statusText[row.status] }}</template>
      </el-table-column>
      <el-table-column prop="date" label="下单日期" />
    </el-table>

    <el-card shadow="never" class="report">
      <template #header>本地回归 {{ localPassed }}/{{ localReport.length }}</template>
      <p>锁基准日 2026-10-10,不请求模型。大于 1000 应命中 A1002、A1010;大于等于再多 A1001。</p>
      <ul>
        <li v-for="item in localReport" :key="item.name" :class="item.ok ? 'ok' : 'bad'">
          {{ item.ok ? '通过' : '失败' }} {{ item.name }}
        </li>
      </ul>
      <el-button :loading="loading" @click="runModelCases">用 10 句人话打模型</el-button>
      <ul>
        <li v-for="item in modelReport" :key="item.name" :class="item.ok ? 'ok' : 'bad'">
          {{ item.ok ? '通过' : '失败' }} {{ item.name }}
          <span v-if="item.detail"> --- {{ item.detail }}</span>
        </li>
      </ul>
    </el-card>
  </div>
</template>

<style scoped>
.page { padding: 24px; }
.sentence { display: flex; gap: 12px; margin-bottom: 12px; }
.query { margin-top: 12px; }
.summary { margin: 0; color: #334155; }
.query-text {
  margin: 8px 0 0;
  padding: 12px;
  background: #0f172a;
  color: #e2e8f0;
  border-radius: 8px;
  overflow: auto;
}
.table, .report { margin-top: 16px; }
.warn { margin-bottom: 8px; }
.error { color: #dc2626; }
.ok { color: #15803d; }
.bad { color: #b91c1c; }
:deep(.boundary) { --el-table-tr-bg-color: #fef9c3; }
</style>

App.vue 先挂这个页面:

html 复制代码
<script setup lang="ts">
import OrderFilter from './views/OrderFilter.vue'
</script>

<template>
  <OrderFilter />
</template>

打开页面,本地回归应是 10/10。这一步没有 Key 也能过。输入默认句子,点生成,表格应剩 A1002、A1010。把句子里的「大于」改成「大于等于」再生成,黄底 A1001 出现。请求 JSON 里 minInclusive 从 false 变成 true。

手改「包含等于」时,表格跟着变。模型填错边界,运营可以自己勾,不必再发一句。

接真实接口时,把 queryText 对应的对象交给现有的订单查询。分页、排序仍走原来的接口,这次只换筛选参数从哪来。

九、Key 放哪

.env.local 不要提交:

bash 复制代码
AI_API_KEY=sk-你的密钥
AI_BASE_URL=https://api.deepseek.com
VITE_AI_MODEL=deepseek-chat

vite.config.ts:

ts 复制代码
import { defineConfig, loadEnv } from 'vite'
import vue from '@vitejs/plugin-vue'

export default defineConfig(({ mode }) => {
  const env = loadEnv(mode, process.cwd(), '')
  return {
    plugins: [vue()],
    server: {
      proxy: {
        '/ai': {
          target: env.AI_BASE_URL,
          changeOrigin: true,
          rewrite: (path) => path.replace(/^\/ai/, ''),
          headers: { Authorization: `Bearer ${env.AI_API_KEY}` },
        },
      },
    },
  }
})

改完重启 npm run dev。AI_API_KEY 没有 VITE_ 前缀,打包进页面的只有模型名。

换模型只改这两个变量。

服务 AI_BASE_URL VITE_AI_MODEL
DeepSeek https://api.deepseek.com deepseek-chat
通义千问 https://dashscope.aliyuncs.com/compatible-mode/v1 qwen-plus
智谱 https://open.bigmodel.cn/api/paas/v4 glm-4-flash
Kimi https://api.moonshot.cn/v1 moonshot-v1-8k

某个模型不认 response_format,删掉这个字段即可。extractJson 仍能从代码块里取 JSON。

打包之后没有 Vite 代理。上线把 /ai/chat/completions 换成自己的后端,Key 留在服务器,返回的 JSON 继续交给 interpret。10 条本地用例跟后端无关,改收口逻辑时照样先跑。

十、对单时另外碰到的问题

本地回归不是 10/10。 看失败那条的名字。日期差一天,查周日是不是退了 6 天。金额差在 1000 这一单上,查 minInclusive,不要把大于写成 >=。

模型 10 句里只有「以上」失败。 提示词和 SENTENCE_CASES 对这个词的定义不一致。两处改成同一个含义再打。

警告写了收成上海,下拉框却是空的。 别名是整词。「上海市」可以,「上海站」不行。要做包含匹配,先加一条用例,写明「北京」不能误伤别的词。

清空金额变成 0。 el-input-number 写了 :value-on-clear="null"。旧版本没有这个属性时,清空后把 undefined 写回 null。留下 0 会变成大于等于 0,10 张订单全部进来。

日期框差一天。 el-date-picker 要 value-format="YYYY-MM-DD"。值若是 Date 对象,和订单字符串 "2026-09-29" 比,结果会空。

401。 Key 没进当前终端,或者改完 .env.local 没有重启。本地 10/10 仍然该是绿的,红的只是模型那一排。

写在后面

这句话值不值得接进后台,看运营是不是还在用微信发筛选。五个固定下拉框就能覆盖的页面,不必加模型。

接了之后,模型只拆句子。大于含不含、上周从周几起、城市叫什么,以这 10 张订单和 10 条脏数据为准。本地 10 条挂了先改代码,模型 10 句挂了先改提示词。期望都写在 expectFilter 里。

相关推荐
刘科领1 小时前
使用ollama & openweb-ui 本地搭建自己的AI
人工智能·python·ui·ai
不开大的凯20771 小时前
AI权力游戏:同一天,两种答案,一场算力暗战
大数据·人工智能·ai·ai office
零基础1231 小时前
自动驾驶中的边界情况处理:基于世界模型与混合专家及大语言模型的方法
人工智能·语言模型·自动驾驶
DongQiShanRen1 小时前
玄龙(上):TICK 主循环——意识心跳怎么跳
linux·jvm·数据库·人工智能·数据挖掘·rust
研來如此1 小时前
OpenCV官方下载界面下载opencv库
人工智能·opencv·计算机视觉
yi0112 小时前
LeetCode 643. 子数组最大平均数 I|从暴力枚举到滑动窗口
人工智能·笔记·python·算法·leetcode·滑动窗口
天远API2 小时前
零信任架构实战:基于天远风控经营异常预警构建自动化供应商店铺巡检网关
人工智能·架构·自动化·dubbo
Jmyd01232 小时前
用 Web VR 引擎搭建 3D 虚拟展厅:从素材上云到多端实时渲染的完整数据链路
前端·3d·vr·三维数字化