feat(资讯): InformationListView 加发布功能 — 新增/编辑弹窗(标题/一级分类下拉/封面图上传/内容), 走 POST /other/information/save|update + /sys/file/upload; 分类来自 /other/category/list?type=3&level=1(排除无level脏数据9999); 修复 devbox 构建缺 @types/node; 验证: admin 弹窗分类下拉含行业动态, API 创建行业动态新闻后前端 NewsView 行业动态tab显示图片(Playwright 9/9 PASS)

This commit is contained in:
zou 2026-08-14 14:58:03 +08:00
parent 906a675136
commit fc7d9f9179
3 changed files with 233 additions and 14 deletions

18
package-lock.json generated
View File

@ -20,6 +20,7 @@
"vue-router": "^4.3.0"
},
"devDependencies": {
"@types/node": "^26.2.0",
"@types/qs": "^6.15.1",
"@vitejs/plugin-vue": "^5.0.4",
"sass": "^1.71.1",
@ -1280,6 +1281,16 @@
"@types/lodash": "*"
}
},
"node_modules/@types/node": {
"version": "26.2.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.2.0.tgz",
"integrity": "sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~8.3.0"
}
},
"node_modules/@types/qs": {
"version": "6.15.1",
"resolved": "https://registry.npmmirror.com/@types/qs/-/qs-6.15.1.tgz",
@ -2913,6 +2924,13 @@
"dev": true,
"license": "MIT"
},
"node_modules/undici-types": {
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz",
"integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==",
"dev": true,
"license": "MIT"
},
"node_modules/unimport": {
"version": "3.14.6",
"resolved": "https://registry.npmmirror.com/unimport/-/unimport-3.14.6.tgz",

View File

@ -22,6 +22,7 @@
"vue-router": "^4.3.0"
},
"devDependencies": {
"@types/node": "^26.2.0",
"@types/qs": "^6.15.1",
"@vitejs/plugin-vue": "^5.0.4",
"sass": "^1.71.1",

View File

@ -1,13 +1,17 @@
<script setup lang="ts">
/**
* F006 资讯列表 - P1 真 view
* F006 资讯列表 - 真 view + 发布功能 (2026-08-08 增强)
*
* 走 /other/information/page
* 真实接口 (2026-08-07 探测: 200, total=7)
* 字段: id/title/categoryId/categoryName/content/createTime
* 列表: GET /other/information/page
* 新增: POST /other/information/save (title/categoryId/categoryName/content/images)
* 修改: POST /other/information/update
* 删除: POST /other/information/delete (body {id})
* 分类: GET /other/category/list?type=3&level=1 (一级分类, 排除无 level 的脏数据)
* 图片: POST /sys/file/upload (FormData, 返 data.url) — 参考旧系统 information-add-or-update.vue
*/
import { onMounted, ref, reactive } from 'vue'
import { httpGet } from '@/utils/request'
import { onMounted, ref, reactive, computed } from 'vue'
import { httpGet, httpPost, httpDelete } from '@/utils/request'
import { ElMessage, ElMessageBox } from 'element-plus'
import DataTable from '@/components/DataTable/DataTable.vue'
import PageHeader from '@/components/PageHeader/PageHeader.vue'
@ -16,6 +20,8 @@ interface Info {
title: string
categoryId: string
categoryName: string
content: string
images: string
createTime: string
}
@ -23,39 +29,233 @@ const list = ref<Info[]>([])
const total = ref(0)
const loading = ref(false)
const query = reactive({ page: 1, limit: 10 })
const search = reactive({ title: '' })
const categories = ref<{ id: string; name: string }[]>([])
const categoryLoading = ref(false)
// === 弹窗状态 ===
const dialogVisible = ref(false)
const editMode = ref<'create' | 'update'>('create')
const saving = ref(false)
const uploadLoading = ref(false)
const editForm = reactive({
id: '' as string,
title: '',
categoryId: '' as string,
categoryName: '',
content: '',
images: '',
})
const columns = [
{ prop: 'id', label: 'ID', width: 90 },
{ prop: 'title', label: '资讯标题', minWidth: 280 },
{ prop: 'categoryName', label: '分类', width: 140, formatter: (r: Info) => r.categoryName || '--' },
{ prop: 'createTime', label: '发布时间', width: 180 }
{ prop: 'title', label: '资讯标题', minWidth: 260 },
{ prop: 'categoryName', label: '分类', width: 120, formatter: (r: Info) => r.categoryName || '--' },
{ prop: 'createTime', label: '发布时间', width: 170 },
]
const imgStyle = computed(() => ({
maxWidth: '140px',
maxHeight: '48px',
objectFit: 'cover' as const,
borderRadius: '4px',
}))
async function load() {
loading.value = true
try {
const resp: any = await httpGet('/other/information/page', { page: query.page, limit: query.limit })
const params: any = { page: query.page, limit: query.limit }
if (search.title.trim()) params.title = search.title.trim()
const resp: any = await httpGet('/other/information/page', params)
list.value = resp?.list || []
total.value = Number(resp?.total || 0)
} catch { list.value = []; total.value = 0 } finally { loading.value = false }
}
function onPageChange(p: number) { query.page = p; load() }
function onLimitChange(l: number) { query.limit = l; query.page = 1; load() }
onMounted(load)
function onSearch() { query.page = 1; load() }
function onReset() { search.title = ''; query.page = 1; load() }
// === 分类 ===
async function loadCategories() {
categoryLoading.value = true
try {
const data: any = await httpGet('/other/category/list', { type: 3, level: 1 })
categories.value = (data || [])
.filter((c: any) => c.level === 1 && c.id)
.map((c: any) => ({ id: String(c.id), name: c.name }))
.sort((a: any, b: any) => (a.name > b.name ? 1 : -1))
} catch { categories.value = [] } finally { categoryLoading.value = false }
}
// === 新增/编辑 ===
function openCreate() {
editMode.value = 'create'
editForm.id = ''; editForm.title = ''; editForm.categoryId = ''; editForm.categoryName = ''; editForm.content = ''; editForm.images = ''
dialogVisible.value = true
loadCategories()
}
function openEdit(row: Info) {
editMode.value = 'update'
editForm.id = String(row.id)
editForm.title = row.title || ''
editForm.categoryId = row.categoryId ? String(row.categoryId) : ''
editForm.categoryName = row.categoryName || ''
editForm.content = row.content || ''
editForm.images = row.images || ''
dialogVisible.value = true
loadCategories()
}
function onCategoryChange(id: string) {
const c = categories.value.find(x => x.id === id)
editForm.categoryName = c?.name || ''
}
async function submit() {
if (!editForm.title.trim()) { ElMessage.warning('请填写标题'); return }
if (!editForm.categoryId) { ElMessage.warning('请选择分类'); return }
saving.value = true
try {
const payload: any = {
title: editForm.title.trim(),
categoryId: editForm.categoryId,
categoryName: editForm.categoryName,
content: editForm.content,
images: editForm.images,
}
if (editMode.value === 'create') {
await httpPost('/other/information/save', payload)
ElMessage.success('新增成功')
} else {
payload.id = editForm.id
await httpPost('/other/information/update', payload)
ElMessage.success('修改成功')
}
dialogVisible.value = false
load()
} catch (e: any) {
ElMessage.error(e?.msg || e?.message || '保存失败')
} finally { saving.value = false }
}
async function onDelete(row: Info) {
try {
await ElMessageBox.confirm(`确定删除资讯「${row.title}」吗?`, '提示', { type: 'warning' })
await httpDelete('/other/information/delete', { id: row.id })
ElMessage.success('删除成功')
load()
} catch (e: any) {
if (e === 'cancel' || e === 'close') return
ElMessage.error(e?.msg || e?.message || '删除失败')
}
}
// === 图片上传 (FormData + Bearer 头由拦截器自动带) ===
async function customUpload(opt: any) {
const file = opt.file as File
if (file.size > 2 * 1024 * 1024) { ElMessage.error('图片大小不能超过 2MB!'); return }
uploadLoading.value = true
try {
const fd = new FormData()
fd.append('file', file)
const { http } = await import('@/utils/request')
const res: any = await http.post('/sys/file/upload', fd, {
headers: { 'Content-Type': 'multipart/form-data' },
})
const body = res?.data
if (body?.code === 0 && body?.data?.url) {
editForm.images = body.data.url
ElMessage.success('图片上传成功')
} else {
ElMessage.error('图片上传失败: ' + (body?.msg || '未知错误'))
}
} catch (e: any) {
ElMessage.error('图片上传失败: ' + (e?.message || '网络错误'))
} finally { uploadLoading.value = false }
}
</script>
<template>
<div class="rr-page">
<PageHeader title="资讯列表" :sub="`平台资讯 (共 ${total} 条)`" />
<PageHeader title="资讯列表" :sub="`平台资讯 (共 ${total} 条)`">
<el-button type="primary" @click="openCreate()">新增资讯</el-button>
</PageHeader>
<div class="rr-card">
<div class="rr-filters-row" style="padding: 16px 16px 0;">
<el-form inline>
<el-form-item label="标题">
<el-input v-model="search.title" placeholder="搜索标题" clearable style="width: 220px" @keyup.enter="onSearch" />
</el-form-item>
<el-form-item>
<el-button type="primary" @click="onSearch">查询</el-button>
<el-button @click="onReset">重置</el-button>
</el-form-item>
</el-form>
</div>
<DataTable :columns="columns" :data="list"
:borderless="true" :loading="loading" :total="total" :page="query.page" :limit="query.limit" :show-pagination="true" row-key="id" empty-text="暂无资讯" @page-change="onPageChange"
@limit-change="onLimitChange" />
@limit-change="onLimitChange">
<template #default="{ row }">
<el-table-column label="封面" width="160">
<template #default="{ row: r }">
<img v-if="r.images" :src="r.images" :style="imgStyle" alt="封面" referrerpolicy="no-referrer" />
<span v-else class="rr-muted">无图</span>
</template>
</el-table-column>
<el-table-column label="操作" width="160" fixed="right">
<template #default="{ row: r }">
<span class="rr-actions">
<el-link type="primary" @click="openEdit(r)">编辑</el-link>
<span class="rr-actions__sep">|</span>
<el-link type="danger" @click="onDelete(r)">删除</el-link>
</span>
</template>
</el-table-column>
</template>
</DataTable>
</div>
<!-- 新增/编辑弹窗 -->
<el-dialog v-model="dialogVisible" :title="editMode === 'create' ? '新增资讯' : '编辑资讯'" width="640px" destroy-on-close>
<el-form label-width="90px" label-position="right">
<el-form-item label="标题" required>
<el-input v-model="editForm.title" placeholder="资讯标题" maxlength="50" show-word-limit />
</el-form-item>
<el-form-item label="一级分类" required>
<el-select v-model="editForm.categoryId" placeholder="请选择一级分类" :loading="categoryLoading" filterable style="width: 100%" @change="onCategoryChange">
<el-option v-for="c in categories" :key="c.id" :label="c.name" :value="c.id" />
</el-select>
</el-form-item>
<el-form-item label="封面图片">
<div class="flex items-center gap-3">
<el-upload
:show-file-list="false"
accept="image/jpeg,image/png,image/jpg"
:http-request="customUpload"
:disabled="uploadLoading">
<img v-if="editForm.images" :src="editForm.images" alt="封面" class="upload-preview" referrerpolicy="no-referrer" />
<el-button v-else :loading="uploadLoading">上传封面</el-button>
</el-upload>
<span class="rr-muted">支持 JPG/PNG, 不超过 2MB</span>
</div>
</el-form-item>
<el-form-item label="资讯内容">
<el-input v-model="editForm.content" type="textarea" :rows="8" placeholder="资讯内容(支持 HTML)" maxlength="20000" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" :loading="saving" @click="submit">保存</el-button>
</template>
</el-dialog>
</div>
</template>
<style scoped lang="scss">
.rr-page { max-width: 1400px; }
.rr-card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-lg); overflow: hidden; }
.rr-filters-row { display: flex; }
.rr-muted { color: var(--text-muted, #909399); font-size: 12px; }
.rr-actions { display: inline-flex; align-items: center; gap: 4px; }
.rr-actions__sep { color: var(--border, #e4e7ed); }
.upload-preview { max-width: 160px; max-height: 80px; object-fit: cover; border-radius: 6px; border: 1px solid var(--border, #e4e7ed); }
</style>