yeg
2026-08-29 ff901c24e75f80c87f19b7d72a3bf00ad6d0421d
实现 Ticket05 前端持久化验收

接通电池组状态与删除操作,移除字段 localStorage 事实来源,补充字段持久化契约和浏览器验收脚本。
1个文件已添加
5个文件已修改
222 ■■■■■ 已修改文件
package.json 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/batteryGroups.ts 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/stores/batteryGroupFields.ts 114 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/BatteryGroup.vue 33 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tests/battery-group-persistence.contract.test.mjs 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tests/e2e/shared-fields-and-battery-groups.spec.ts 55 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
package.json
@@ -7,7 +7,8 @@
    "dev": "vite",
    "build": "vue-tsc -b && vite build",
    "preview": "vite preview",
    "test:e2e:ticket04": "playwright test tests/e2e/device-registration.spec.ts"
    "test:e2e:ticket04": "playwright test tests/e2e/device-registration.spec.ts",
    "test:e2e:ticket05": "playwright test tests/e2e/shared-fields-and-battery-groups.spec.ts"
  },
  "dependencies": {
    "@element-plus/icons-vue": "^2.3.2",
src/api/batteryGroups.ts
@@ -182,3 +182,13 @@
    body: JSON.stringify({ groupId, ...payload }),
  })
}
export function changeBatteryGroupStatus(groupId: number, status: '0' | '1'): Promise<ApiResponse> {
  return request<ApiResponse>('/system/batteryGroup/changeStatus', {
    method: 'PUT', body: JSON.stringify({ groupId, status }),
  })
}
export function deleteBatteryGroup(groupId: number): Promise<ApiResponse> {
  return request<ApiResponse>(`/system/batteryGroup/${groupId}`, { method: 'DELETE' })
}
src/stores/batteryGroupFields.ts
@@ -1,85 +1,63 @@
import { ref } from 'vue'
import type { FieldType } from '@/stores/batteryGroups'
import {
  createBatteryGroupField,
  deleteBatteryGroupField,
  fieldTypeCode,
  getBatteryGroupFields,
  updateBatteryGroupField,
  type BatteryGroupField,
} from '@/api/batteryGroupFields'
export type BatteryGroupFieldStatus = '启用' | '禁用'
// 报表和控制台仍通过该 store 读取字段;数据由后端 MySQL 提供,避免业务字段落入浏览器 localStorage。
const fields = ref<BatteryGroupField[]>([])
let refreshPromise: Promise<BatteryGroupField[]> | null = null
export interface BatteryGroupField {
  id: number
  name: string
  type: FieldType
  creator: string
  createdAt: string
  status: BatteryGroupFieldStatus
}
const STORAGE_KEY = 'battery_detection_group_fields_v1'
const seedFields: BatteryGroupField[] = [
  { id: 1086, name: '额定容量', type: '数字', creator: '张工', createdAt: '2026-08-05 09:30', status: '启用' },
  { id: 1085, name: '电流', type: '数字', creator: '李工', createdAt: '2026-08-04 16:18', status: '启用' },
  { id: 1084, name: '内阻', type: '数字', creator: '周工', createdAt: '2026-08-04 11:42', status: '启用' },
  { id: 1083, name: '循环温度', type: '数字', creator: '王工', createdAt: '2026-08-03 15:06', status: '禁用' },
  { id: 1082, name: '生产批次', type: '文本', creator: '赵工', createdAt: '2026-08-02 10:24', status: '启用' },
  { id: 1081, name: '生产日期', type: '时间', creator: '陈工', createdAt: '2026-08-01 14:10', status: '启用' },
  { id: 1080, name: '材料备注', type: '备注', creator: 'Admin', createdAt: '2026-08-01 09:26', status: '启用' },
]
function inferFieldType(name: string): FieldType {
  if (/日期|时间/.test(name)) return '时间'
  if (/备注|说明|描述/.test(name)) return '备注'
  if (/容量|电流|电压|内阻|温度|数量|厚度|长度|宽度|重量|次数/.test(name)) return '数字'
  return '文本'
}
function loadFields() {
  try {
    const stored = localStorage.getItem(STORAGE_KEY)
    if (stored) {
      const parsed = JSON.parse(stored) as Array<Partial<BatteryGroupField> & Pick<BatteryGroupField, 'id' | 'name' | 'creator' | 'createdAt' | 'status'>>
      return parsed.map(item => ({ ...item, type: item.type || inferFieldType(item.name) })) as BatteryGroupField[]
    }
  } catch {
    localStorage.removeItem(STORAGE_KEY)
function toPayload(data: Pick<BatteryGroupField, 'name' | 'type' | 'status'>) {
  return {
    fieldName: data.name.trim(),
    fieldType: fieldTypeCode[data.type],
    status: data.status === '启用' ? '0' as const : '1' as const,
  }
  return seedFields
}
const fields = ref<BatteryGroupField[]>(loadFields())
function persist() {
  localStorage.setItem(STORAGE_KEY, JSON.stringify(fields.value))
}
function nowString() {
  const date = new Date()
  const pad = (value: number) => String(value).padStart(2, '0')
  return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`
}
export function useBatteryGroupFields() {
  function createField(data: Pick<BatteryGroupField, 'name' | 'type' | 'creator' | 'status'>) {
    const id = Math.max(1000, ...fields.value.map(item => item.id)) + 1
    fields.value.unshift({ ...data, id, createdAt: nowString() })
    persist()
    return id
  async function refreshFields() {
    if (!refreshPromise) {
      refreshPromise = getBatteryGroupFields()
        .then(result => {
          fields.value.splice(0, fields.value.length, ...result)
          return fields.value
        })
        .finally(() => {
          refreshPromise = null
        })
    }
    return refreshPromise
  }
  function updateField(id: number, data: Pick<BatteryGroupField, 'name' | 'type' | 'creator' | 'status'>) {
    const target = fields.value.find(item => item.id === id)
    if (!target) return false
    Object.assign(target, data)
    persist()
  async function createField(data: Pick<BatteryGroupField, 'name' | 'type' | 'creator' | 'status'>) {
    await createBatteryGroupField(toPayload(data))
    await refreshFields()
    return fields.value[0]?.id
  }
  async function updateField(id: number, data: Pick<BatteryGroupField, 'name' | 'type' | 'creator' | 'status'>) {
    await updateBatteryGroupField(id, toPayload(data))
    await refreshFields()
    return true
  }
  function removeField(id: number) {
    fields.value = fields.value.filter(item => item.id !== id)
    persist()
  async function removeField(id: number) {
    await deleteBatteryGroupField(id)
    await refreshFields()
  }
  function saveChanges() {
    persist()
  async function saveChanges() {
    await refreshFields()
  }
  return { fields, createField, updateField, removeField, saveChanges }
  void refreshFields()
  return { fields, refreshFields, createField, updateField, removeField, saveChanges }
}
export type { BatteryGroupField }
src/views/BatteryGroup.vue
@@ -1,8 +1,8 @@
<script setup lang="ts">
import { onMounted, reactive, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useRouter } from 'vue-router'
import { getBatteryGroupCreatorOptions, getBatteryGroupList, type BatteryGroupCreatorOption, type BatteryGroupListItem, type GroupStatus } from '@/api/batteryGroups'
import { changeBatteryGroupStatus, deleteBatteryGroup, getBatteryGroupCreatorOptions, getBatteryGroupList, type BatteryGroupCreatorOption, type BatteryGroupListItem, type GroupStatus } from '@/api/batteryGroups'
interface Filters {
  id: string
@@ -103,12 +103,31 @@
  router.push(`/battery-group/${item.id}/edit`)
}
function toggleStatus() {
  ElMessage.info('电池组启用/禁用接口尚未对接')
async function toggleStatus(item: BatteryGroupListItem) {
  const nextStatus: GroupStatus = item.status === '启用' ? '禁用' : '启用'
  try {
    await ElMessageBox.confirm(`确认${nextStatus}电池组“${item.name}”?`, `${nextStatus}电池组`, {
      confirmButtonText: nextStatus, cancelButtonText: '取消', type: 'warning',
    })
    await changeBatteryGroupStatus(item.id, nextStatus === '启用' ? '0' : '1')
    ElMessage.success(`电池组已${nextStatus}`)
    await loadGroups()
  } catch (error) {
    if (error instanceof Error && error.message !== 'cancel') ElMessage.error(error.message || '更新电池组状态失败')
  }
}
function removeGroup() {
  ElMessage.info('电池组删除接口尚未对接')
async function removeGroup(item: BatteryGroupListItem) {
  try {
    await ElMessageBox.confirm(`删除电池组“${item.name}”后无法恢复,是否继续?`, '确认删除电池组', {
      confirmButtonText: '删除', cancelButtonText: '取消', type: 'warning', confirmButtonClass: 'danger-confirm',
    })
    await deleteBatteryGroup(item.id)
    ElMessage.success('电池组已删除')
    await loadGroups()
  } catch (error) {
    if (error instanceof Error && error.message !== 'cancel') ElMessage.error(error.message || '删除电池组失败')
  }
}
watch([currentPage, pageSize], ([, newPageSize], [, oldPageSize]) => {
@@ -160,7 +179,7 @@
          <tbody>
            <tr v-for="item in groups" :key="item.id">
              <td v-if="isColumnVisible('id')" class="id-cell">#{{ item.id }}</td><td v-if="isColumnVisible('name')"><strong>{{ item.name }}</strong></td><td v-if="isColumnVisible('model')" class="code-cell">{{ item.model }}</td><td v-if="isColumnVisible('purpose')" :title="item.purpose">{{ item.purpose }}</td><td v-if="isColumnVisible('batteryCount')">{{ item.batteryCount }} 块</td><td v-if="isColumnVisible('createdAt')">{{ item.createdAt }}</td><td v-if="isColumnVisible('status')"><span class="status-tag" :class="item.status === '启用' ? 'enabled' : 'disabled'"><i></i>{{ item.status }}</span></td><td v-if="isColumnVisible('creator')">{{ item.creator }}</td>
              <td class="action-column"><div class="row-actions"><button type="button" @click="editGroup(item)">编辑</button><button type="button" :class="item.status === '启用' ? 'disable-action' : 'enable-action'" @click="toggleStatus">{{ item.status === '启用' ? '禁用' : '启用' }}</button><button class="delete-action" type="button" @click="removeGroup">删除</button></div></td>
              <td class="action-column"><div class="row-actions"><button type="button" @click="editGroup(item)">编辑</button><button type="button" :class="item.status === '启用' ? 'disable-action' : 'enable-action'" @click="toggleStatus(item)">{{ item.status === '启用' ? '禁用' : '启用' }}</button><button class="delete-action" type="button" @click="removeGroup(item)">删除</button></div></td>
            </tr>
            <tr v-if="!groups.length"><td :colspan="visibleColumns.length + 1" class="empty-cell">{{ loading ? '正在加载电池组...' : '暂无符合条件的电池组' }}</td></tr>
          </tbody>
tests/battery-group-persistence.contract.test.mjs
@@ -1,4 +1,5 @@
import assert from 'node:assert/strict'
import fs from 'node:fs'
import test from 'node:test'
import { buildBatteryGroupPayload } from '../src/api/batteryGroupPayload.ts'
@@ -40,3 +41,9 @@
  assert.equal(payload.templateId, 7)
  assert.deepEqual(payload.templateFieldRelations, [{ batteryGroupFieldId: 3, fieldValue: '72Ah' }])
})
test('shared field store uses the backend as its source of truth', () => {
  const source = fs.readFileSync(new URL('../src/stores/batteryGroupFields.ts', import.meta.url), 'utf8')
  assert.doesNotMatch(source, /localStorage\./)
  assert.match(source, /getBatteryGroupFields/)
})
tests/e2e/shared-fields-and-battery-groups.spec.ts
New file
@@ -0,0 +1,55 @@
import { expect, test, type Page } from '@playwright/test'
import { Ticket04Stack } from './support/ticket04-stack'
const stack = new Ticket04Stack()
test.beforeAll(async () => stack.start())
test.afterAll(async () => stack.stop())
async function login(page: Page) {
  await page.goto('/login')
  await page.locator('input[autocomplete="username"]').fill('admin')
  await page.locator('input[autocomplete="current-password"]').fill('admin123')
  await page.getByRole('button', { name: '登录', exact: true }).click()
  await expect(page).toHaveURL(/\/dashboard$/)
}
test('共享字段、电池组和电池号从后端事实恢复', async ({ page }) => {
  const suffix = Date.now()
  const fieldName = `验收容量${suffix}`
  const templateName = `验收模板${suffix}`
  const groupName = `验收电池组${suffix}`
  await login(page)
  await page.goto('/battery-group/fields')
  await page.getByRole('button', { name: '新增字段' }).click()
  await page.locator('.field-dialog label').filter({ hasText: '字段名称' }).locator('input').fill(fieldName)
  await page.locator('.field-dialog .el-select').click()
  await page.getByRole('option', { name: '数字', exact: true }).click()
  await page.getByRole('button', { name: '保存', exact: true }).last().click()
  await expect(page.locator('tbody tr').filter({ hasText: fieldName })).toBeVisible()
  await page.goto('/battery-group/new')
  await page.getByRole('button', { name: '编辑当前模板' }).click()
  await page.getByRole('checkbox', { name: fieldName, exact: true }).check()
  await page.getByRole('button', { name: '另存模板' }).click()
  await page.locator('.el-message-box__input input').fill(templateName)
  await page.getByRole('button', { name: '保存', exact: true }).last().click()
  await expect(page.locator('.template-actions .el-select')).toContainText(templateName)
  await page.locator('label').filter({ hasText: '电池组名称' }).locator('input').fill(groupName)
  await page.locator('label').filter({ hasText: '型号' }).locator('input').fill('M-TICKET05')
  await page.locator('label').filter({ hasText: '目的' }).locator('textarea').fill('Ticket05 持久化验收')
  await page.locator('.battery-row input').first().fill(`BAT-${suffix}`)
  await page.locator('label').filter({ hasText: fieldName }).locator('input').fill('72')
  await page.getByRole('button', { name: '保存新建电池组', exact: true }).click()
  await expect(page).toHaveURL(/\/battery-group$/)
  await expect(page.locator('tbody tr').filter({ hasText: groupName })).toBeVisible()
  await page.locator('tbody tr').filter({ hasText: groupName }).getByRole('button', { name: '编辑' }).click()
  await expect(page.locator('label').filter({ hasText: fieldName }).locator('input')).toHaveValue('72')
  await expect(page.locator('.battery-row input').first()).toHaveValue(`BAT-${suffix}`)
  await page.reload()
  await expect(page.locator('label').filter({ hasText: fieldName }).locator('input')).toHaveValue('72')
  await expect(page.locator('.battery-row input').first()).toHaveValue(`BAT-${suffix}`)
})