实现 Ticket05 前端持久化验收
接通电池组状态与删除操作,移除字段 localStorage 事实来源,补充字段持久化契约和浏览器验收脚本。
| | |
| | | "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", |
| | |
| | | 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' }) |
| | | } |
| | |
| | | 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 } |
| | |
| | | <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 |
| | |
| | | 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]) => { |
| | |
| | | <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> |
| | |
| | | import assert from 'node:assert/strict' |
| | | import fs from 'node:fs' |
| | | import test from 'node:test' |
| | | import { buildBatteryGroupPayload } from '../src/api/batteryGroupPayload.ts' |
| | | |
| | |
| | | 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/) |
| | | }) |
| New file |
| | |
| | | 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}`) |
| | | }) |