Merge branch 'master' of https://git.yunzer.cn/yunzerwebsite/platform-vue
This commit is contained in:
commit
0aa25cead2
5
.gitignore
vendored
5
.gitignore
vendored
@ -9,8 +9,13 @@ lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
output
|
||||
dist-ssr
|
||||
*.local
|
||||
output.zip
|
||||
dist.zip
|
||||
dist.7z
|
||||
output.7z
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
|
||||
@ -5,6 +5,7 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --open",
|
||||
"clean": "node scripts/clean-dist.mjs",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
|
||||
49
scripts/clean-dist.mjs
Normal file
49
scripts/clean-dist.mjs
Normal file
@ -0,0 +1,49 @@
|
||||
/**
|
||||
* 构建前删除 dist,带重试。缓解 Windows 上 Vite emptyDir 的 EPERM(文件被资源管理器预览、杀毒、vite preview 等占用)。
|
||||
*/
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const root = path.resolve(__dirname, "..");
|
||||
const dirs = [path.join(root, "dist"), path.join(root, "output")];
|
||||
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
async function rmDirWithRetry(dir) {
|
||||
for (let i = 0; i < 10; i++) {
|
||||
if (!fs.existsSync(dir)) {
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
return true;
|
||||
} catch (e) {
|
||||
const code = e && typeof e === "object" && "code" in e ? e.code : "";
|
||||
const retryable = code === "EPERM" || code === "EBUSY" || code === "ENOTEMPTY";
|
||||
if (!retryable || i === 9) {
|
||||
console.error(`[clean-dist] 无法删除 ${path.relative(root, dir) || dir}:`, e instanceof Error ? e.message : e);
|
||||
return false;
|
||||
}
|
||||
await sleep(350 * (i + 1));
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
let ok = true;
|
||||
for (const dir of dirs) {
|
||||
const r = await rmDirWithRetry(dir);
|
||||
if (!r) ok = false;
|
||||
}
|
||||
if (!ok) {
|
||||
console.error(
|
||||
"请关闭占用上述目录的程序:资源管理器预览、vite preview、IDE、杀毒实时扫描等后执行 npm run clean。"
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
await main();
|
||||
78
src/api/complaint.js
Normal file
78
src/api/complaint.js
Normal file
@ -0,0 +1,78 @@
|
||||
import request from "@/utils/request";
|
||||
|
||||
/** 投诉建议列表 */
|
||||
export function getComplaintList(params) {
|
||||
return request({
|
||||
url: "/platform/complaint/list",
|
||||
method: "get",
|
||||
params,
|
||||
});
|
||||
}
|
||||
|
||||
export function getComplaintDetail(id) {
|
||||
return request({
|
||||
url: `/platform/complaint/${id}`,
|
||||
method: "get",
|
||||
});
|
||||
}
|
||||
|
||||
export function createComplaint(data) {
|
||||
return request({
|
||||
url: "/platform/complaint",
|
||||
method: "post",
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
export function updateComplaint(id, data) {
|
||||
return request({
|
||||
url: `/platform/complaint/${id}`,
|
||||
method: "post",
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
export function deleteComplaint(id) {
|
||||
return request({
|
||||
url: `/platform/complaint/${id}`,
|
||||
method: "delete",
|
||||
});
|
||||
}
|
||||
|
||||
/** 产品分类(投诉建议用) */
|
||||
export function getComplaintCategoryList() {
|
||||
return request({
|
||||
url: "/platform/complaintCategory/list",
|
||||
method: "get",
|
||||
});
|
||||
}
|
||||
|
||||
export function getComplaintCategorySelect() {
|
||||
return request({
|
||||
url: "/platform/complaintCategory/select",
|
||||
method: "get",
|
||||
});
|
||||
}
|
||||
|
||||
export function createComplaintCategory(data) {
|
||||
return request({
|
||||
url: "/platform/complaintCategory",
|
||||
method: "post",
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
export function updateComplaintCategory(id, data) {
|
||||
return request({
|
||||
url: `/platform/complaintCategory/${id}`,
|
||||
method: "post",
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
export function deleteComplaintCategory(id) {
|
||||
return request({
|
||||
url: `/platform/complaintCategory/${id}`,
|
||||
method: "delete",
|
||||
});
|
||||
}
|
||||
@ -104,6 +104,7 @@ export function getFileById(id) {
|
||||
* @param {Object} options 额外选项
|
||||
* @param {string|number} [options.cate] 文件分组,0 为未分类
|
||||
* @param {string|number} [options.tuid] 租户用户 yz_tenant_user.id;租户侧上传时传,平台管理员不传
|
||||
* @param {(e: { loaded: number; total?: number }) => void} [options.onUploadProgress] 上传进度(浏览器 XHR)
|
||||
* @returns {Promise}
|
||||
*/
|
||||
export function uploadFile(formData, options = {}) {
|
||||
@ -115,14 +116,17 @@ export function uploadFile(formData, options = {}) {
|
||||
formData.append("tuid", String(options.tuid));
|
||||
}
|
||||
|
||||
return request({
|
||||
const config = {
|
||||
url: "/platform/uploadfile",
|
||||
method: "post",
|
||||
data: formData,
|
||||
headers: {
|
||||
"Content-Type": "multipart/form-data"
|
||||
}
|
||||
});
|
||||
// 大安装包 / 极弱网:2 小时;勿设置 Content-Type(由浏览器自动带 boundary)
|
||||
timeout: 2 * 60 * 60 * 1000,
|
||||
};
|
||||
if (typeof options.onUploadProgress === "function") {
|
||||
config.onUploadProgress = options.onUploadProgress;
|
||||
}
|
||||
return request(config);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -105,3 +105,20 @@ export function sendResetCode(data) {
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
// 租户端自助注册(/backend/*,与 go-platform routers/backend 一致)
|
||||
export function register(data) {
|
||||
return request({
|
||||
url: "/backend/register",
|
||||
method: "post",
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
export function sendRegisterCode(data) {
|
||||
return request({
|
||||
url: "/backend/sendRegisterCode",
|
||||
method: "post",
|
||||
data,
|
||||
});
|
||||
}
|
||||
39
src/api/softwareUpgrade.js
Normal file
39
src/api/softwareUpgrade.js
Normal file
@ -0,0 +1,39 @@
|
||||
import request from "@/utils/request";
|
||||
|
||||
export function getSoftwareUpgradeList(params) {
|
||||
return request({
|
||||
url: "/platform/softwareupgrade/list",
|
||||
method: "get",
|
||||
params,
|
||||
});
|
||||
}
|
||||
|
||||
export function getSoftwareUpgradeDetail(id) {
|
||||
return request({
|
||||
url: `/platform/softwareupgrade/${id}`,
|
||||
method: "get",
|
||||
});
|
||||
}
|
||||
|
||||
export function createSoftwareUpgrade(data) {
|
||||
return request({
|
||||
url: "/platform/softwareupgrade",
|
||||
method: "post",
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
export function updateSoftwareUpgrade(id, data) {
|
||||
return request({
|
||||
url: `/platform/softwareupgrade/${id}`,
|
||||
method: "post",
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
export function deleteSoftwareUpgrade(id) {
|
||||
return request({
|
||||
url: `/platform/softwareupgrade/${id}`,
|
||||
method: "delete",
|
||||
});
|
||||
}
|
||||
@ -3,10 +3,10 @@ import axios from 'axios';
|
||||
// 获取API基础URL;开发环境可在 .env.development 留空,配合 Vite 代理访问 /platform
|
||||
const apiBaseURL = import.meta.env.VITE_API_BASE_URL ?? "";
|
||||
|
||||
// 创建axios实例
|
||||
// 创建axios实例(普通接口 5min;大文件上传在 api/file.js 单独更长 timeout)
|
||||
const service = axios.create({
|
||||
baseURL: apiBaseURL,
|
||||
timeout: 10000,
|
||||
timeout: 300000,
|
||||
withCredentials: false // JWT 不需要 Cookie
|
||||
});
|
||||
|
||||
@ -18,9 +18,12 @@ service.interceptors.request.use(
|
||||
config.headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
// 对于有 body 的请求(POST、PUT、PATCH),确保设置 Content-Type
|
||||
// 对于有 body 的请求(POST、PUT、PATCH),默认 JSON;FormData 由浏览器带 multipart boundary,不可手写 Content-Type
|
||||
if (config.data && ['post', 'put', 'patch'].includes(config.method?.toLowerCase())) {
|
||||
if (!config.headers['Content-Type'] && !config.headers['content-type']) {
|
||||
if (config.data instanceof FormData) {
|
||||
delete config.headers['Content-Type'];
|
||||
delete config.headers['content-type'];
|
||||
} else if (!config.headers['Content-Type'] && !config.headers['content-type']) {
|
||||
config.headers['Content-Type'] = 'application/json';
|
||||
}
|
||||
}
|
||||
|
||||
216
src/views/platform/complaint/components/edit.vue
Normal file
216
src/views/platform/complaint/components/edit.vue
Normal file
@ -0,0 +1,216 @@
|
||||
<template>
|
||||
<el-drawer
|
||||
v-model="visible"
|
||||
:title="isAdd ? '新增投诉建议' : '处理投诉建议'"
|
||||
size="520px"
|
||||
destroy-on-close
|
||||
@closed="onClosed"
|
||||
>
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-width="100px" v-loading="loading">
|
||||
<el-form-item label="产品分类" prop="categoryId">
|
||||
<el-select
|
||||
v-model="form.categoryId"
|
||||
placeholder="请选择"
|
||||
style="width: 100%"
|
||||
filterable
|
||||
:disabled="!isAdd"
|
||||
>
|
||||
<el-option
|
||||
v-for="c in categoryOptions"
|
||||
:key="c.id"
|
||||
:label="c.name"
|
||||
:value="Number(c.id)"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="标题" prop="title">
|
||||
<el-input v-model="form.title" placeholder="标题" :disabled="!isAdd" />
|
||||
</el-form-item>
|
||||
<el-form-item label="内容" prop="content">
|
||||
<el-input
|
||||
v-model="form.content"
|
||||
type="textarea"
|
||||
:rows="6"
|
||||
placeholder="建议或投诉内容"
|
||||
:disabled="!isAdd"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="联系人">
|
||||
<el-input v-model="form.contactName" :disabled="!isAdd" />
|
||||
</el-form-item>
|
||||
<el-form-item label="电话">
|
||||
<el-input v-model="form.contactPhone" :disabled="!isAdd" />
|
||||
</el-form-item>
|
||||
<el-form-item label="邮箱">
|
||||
<el-input v-model="form.contactEmail" :disabled="!isAdd" />
|
||||
</el-form-item>
|
||||
<template v-if="!isAdd">
|
||||
<el-divider />
|
||||
<el-form-item label="状态" prop="status">
|
||||
<el-select v-model="form.status" style="width: 100%">
|
||||
<el-option :value="0" label="待处理" />
|
||||
<el-option :value="1" label="处理中" />
|
||||
<el-option :value="2" label="已回复" />
|
||||
<el-option :value="3" label="已关闭" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="平台回复">
|
||||
<el-input v-model="form.replyContent" type="textarea" :rows="5" placeholder="填写后保存将记录回复时间" />
|
||||
</el-form-item>
|
||||
<el-form-item label="内部备注">
|
||||
<el-input v-model="form.remark" type="textarea" :rows="2" />
|
||||
</el-form-item>
|
||||
</template>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="visible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="saving" @click="submit">保存</el-button>
|
||||
</template>
|
||||
</el-drawer>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, nextTick } from "vue";
|
||||
import { ElMessage } from "element-plus";
|
||||
import {
|
||||
getComplaintDetail,
|
||||
createComplaint,
|
||||
updateComplaint,
|
||||
getComplaintCategorySelect,
|
||||
} from "@/api/complaint";
|
||||
|
||||
const emit = defineEmits(["saved"]);
|
||||
|
||||
const visible = ref(false);
|
||||
const loading = ref(false);
|
||||
const saving = ref(false);
|
||||
const isAdd = ref(false);
|
||||
const formRef = ref(null);
|
||||
const categoryOptions = ref([]);
|
||||
|
||||
const form = reactive({
|
||||
id: 0,
|
||||
categoryId: null,
|
||||
title: "",
|
||||
content: "",
|
||||
contactName: "",
|
||||
contactPhone: "",
|
||||
contactEmail: "",
|
||||
status: 0,
|
||||
replyContent: "",
|
||||
remark: "",
|
||||
});
|
||||
|
||||
const rules = {
|
||||
categoryId: [{ required: true, message: "请选择产品分类", trigger: "change" }],
|
||||
title: [{ required: true, message: "请输入标题", trigger: "blur" }],
|
||||
content: [{ required: true, message: "请输入内容", trigger: "blur" }],
|
||||
};
|
||||
|
||||
async function loadCategories() {
|
||||
const res = await getComplaintCategorySelect();
|
||||
if (res?.code === 200 && Array.isArray(res.data)) {
|
||||
categoryOptions.value = res.data;
|
||||
} else {
|
||||
categoryOptions.value = [];
|
||||
}
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
form.id = 0;
|
||||
form.categoryId = null;
|
||||
form.title = "";
|
||||
form.content = "";
|
||||
form.contactName = "";
|
||||
form.contactPhone = "";
|
||||
form.contactEmail = "";
|
||||
form.status = 0;
|
||||
form.replyContent = "";
|
||||
form.remark = "";
|
||||
}
|
||||
|
||||
async function open(id) {
|
||||
resetForm();
|
||||
await loadCategories();
|
||||
isAdd.value = !id;
|
||||
visible.value = true;
|
||||
await nextTick();
|
||||
formRef.value?.clearValidate?.();
|
||||
if (id) {
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await getComplaintDetail(id);
|
||||
if (res?.code !== 200 || !res.data) {
|
||||
ElMessage.error(res?.msg || "加载失败");
|
||||
visible.value = false;
|
||||
return;
|
||||
}
|
||||
const d = res.data;
|
||||
form.id = d.id;
|
||||
form.categoryId = d.categoryId;
|
||||
form.title = d.title || "";
|
||||
form.content = d.content || "";
|
||||
form.contactName = d.contactName || "";
|
||||
form.contactPhone = d.contactPhone || "";
|
||||
form.contactEmail = d.contactEmail || "";
|
||||
form.status = d.status ?? 0;
|
||||
form.replyContent = d.replyContent || "";
|
||||
form.remark = d.remark || "";
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function onClosed() {
|
||||
resetForm();
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (!formRef.value) return;
|
||||
try {
|
||||
await formRef.value.validate();
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
saving.value = true;
|
||||
try {
|
||||
if (isAdd.value) {
|
||||
const res = await createComplaint({
|
||||
categoryId: form.categoryId,
|
||||
title: form.title,
|
||||
content: form.content,
|
||||
contactName: form.contactName || undefined,
|
||||
contactPhone: form.contactPhone || undefined,
|
||||
contactEmail: form.contactEmail || undefined,
|
||||
});
|
||||
if (res?.code === 200) {
|
||||
ElMessage.success("已创建");
|
||||
visible.value = false;
|
||||
emit("saved");
|
||||
} else {
|
||||
ElMessage.error(res?.msg || "失败");
|
||||
}
|
||||
} else {
|
||||
const res = await updateComplaint(form.id, {
|
||||
categoryId: form.categoryId,
|
||||
status: form.status,
|
||||
replyContent: form.replyContent,
|
||||
remark: form.remark || undefined,
|
||||
});
|
||||
if (res?.code === 200) {
|
||||
ElMessage.success("已保存");
|
||||
visible.value = false;
|
||||
emit("saved");
|
||||
} else {
|
||||
ElMessage.error(res?.msg || "失败");
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({ open });
|
||||
</script>
|
||||
492
src/views/platform/complaint/index.vue
Normal file
492
src/views/platform/complaint/index.vue
Normal file
@ -0,0 +1,492 @@
|
||||
<template>
|
||||
<div class="container-box">
|
||||
<div class="header-bar">
|
||||
<h2>投诉建议</h2>
|
||||
<div class="header-actions">
|
||||
<el-button @click="apiDocVisible = true">
|
||||
<el-icon><Document /></el-icon>
|
||||
接口说明
|
||||
</el-button>
|
||||
<el-button @click="openCategoryDialog">
|
||||
<el-icon><Collection /></el-icon>
|
||||
产品分类
|
||||
</el-button>
|
||||
<el-button type="primary" @click="editRef.open()">
|
||||
<el-icon><Plus /></el-icon>
|
||||
新增
|
||||
</el-button>
|
||||
<el-button @click="fetchList" :loading="loading">
|
||||
<el-icon><Refresh /></el-icon>
|
||||
刷新
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-divider />
|
||||
|
||||
<el-form :inline="true" :model="searchForm" class="search-form">
|
||||
<el-form-item label="产品分类">
|
||||
<el-select
|
||||
v-model="searchForm.categoryId"
|
||||
placeholder="全部"
|
||||
clearable
|
||||
style="width: 160px"
|
||||
filterable
|
||||
>
|
||||
<el-option
|
||||
v-for="c in categoryFilterOptions"
|
||||
:key="c.id"
|
||||
:label="c.name"
|
||||
:value="Number(c.id)"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="状态">
|
||||
<el-select v-model="searchForm.status" placeholder="全部" clearable style="width: 120px">
|
||||
<el-option :value="0" label="待处理" />
|
||||
<el-option :value="1" label="处理中" />
|
||||
<el-option :value="2" label="已回复" />
|
||||
<el-option :value="3" label="已关闭" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="关键词">
|
||||
<el-input
|
||||
v-model="searchForm.keyword"
|
||||
placeholder="标题/内容/联系方式"
|
||||
clearable
|
||||
style="width: 220px"
|
||||
@keyup.enter="handleSearch"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="handleSearch">查询</el-button>
|
||||
<el-button @click="resetSearch">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<el-table :data="list" v-loading="loading" border style="width: 100%">
|
||||
<el-table-column prop="id" label="ID" width="72" align="center" />
|
||||
<el-table-column prop="categoryName" label="产品分类" width="120" align="center" />
|
||||
<el-table-column prop="title" label="标题" min-width="160" show-overflow-tooltip />
|
||||
<el-table-column prop="content" label="内容" min-width="200" show-overflow-tooltip />
|
||||
<el-table-column label="联系人" width="100" align="center">
|
||||
<template #default="{ row }">{{ row.contactName || "—" }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="电话" width="120" align="center">
|
||||
<template #default="{ row }">{{ row.contactPhone || "—" }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="status" label="状态" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="statusTag(row.status)">{{ statusText(row.status) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="createTime" label="提交时间" width="170" align="center" />
|
||||
<el-table-column label="操作" width="120" align="center" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button text type="primary" size="small" @click="editRef.open(row.id)">处理</el-button>
|
||||
<el-button text type="danger" size="small" @click="handleDelete(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div class="pager">
|
||||
<el-pagination
|
||||
v-model:current-page="pagination.page"
|
||||
v-model:page-size="pagination.pageSize"
|
||||
:total="pagination.total"
|
||||
:page-sizes="[10, 20, 50]"
|
||||
layout="total, sizes, prev, pager, next"
|
||||
@current-change="fetchList"
|
||||
@size-change="fetchList"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ComplaintEdit ref="editRef" @saved="fetchList" />
|
||||
|
||||
<!-- 接口说明(列表页顶栏) -->
|
||||
<el-drawer v-model="apiDocVisible" title="投诉建议 — 接口说明" direction="rtl" size="520px">
|
||||
<el-scrollbar max-height="calc(100vh - 120px)">
|
||||
<div class="api-doc">
|
||||
<p class="api-doc-lead">
|
||||
以下为 go-platform 已提供的 HTTP 接口。请求前缀为当前环境配置的 API 地址(开发环境示例见下方)。
|
||||
</p>
|
||||
<p><strong>API 根地址</strong></p>
|
||||
<pre class="api-pre">{{ apiBase }}</pre>
|
||||
|
||||
<h4>一、鉴权</h4>
|
||||
<p>除特别说明外,均需平台管理员登录后携带:</p>
|
||||
<pre class="api-pre">Authorization: Bearer <token></pre>
|
||||
<p class="api-tip">Token 由 <code>POST /platform/login</code> 返回。列表、分类管理、处理投诉等均为平台端接口。</p>
|
||||
|
||||
<h4>二、产品分类(配置「针对哪类产品提建议」)</h4>
|
||||
<ul>
|
||||
<li><code>GET /platform/complaintCategory/select</code> — 仅返回 <code>status=1</code> 的分类,供下拉/官网选分类用。</li>
|
||||
<li><code>GET /platform/complaintCategory/list</code> — 全部分类(管理列表)。</li>
|
||||
<li><code>POST /platform/complaintCategory</code> — 新增分类,Body JSON:<code>name</code>(必填)、<code>code</code>、<code>sort</code>、<code>status</code>。</li>
|
||||
<li><code>POST /platform/complaintCategory/:id</code> — 更新。</li>
|
||||
<li><code>DELETE /platform/complaintCategory/:id</code> — 软删。</li>
|
||||
</ul>
|
||||
|
||||
<h4>三、提交投诉建议</h4>
|
||||
<p><code>POST /platform/complaint</code></p>
|
||||
<p>Body(JSON,camelCase):</p>
|
||||
<pre class="api-pre">{
|
||||
"categoryId": 1,
|
||||
"title": "建议标题",
|
||||
"content": "详细描述",
|
||||
"contactName": "可选",
|
||||
"contactPhone": "可选",
|
||||
"contactEmail": "可选"
|
||||
}</pre>
|
||||
<p>成功示例:<code>{ "code": 200, "msg": "success", "data": { "id": 1 } }</code></p>
|
||||
<p class="api-tip">若需在官网/小程序<strong>免登录</strong>提交,需在后端单独增加公开路由(当前接口与平台管理一致,需 Bearer)。</p>
|
||||
|
||||
<h4>四、管理端(列表与处理)</h4>
|
||||
<ul>
|
||||
<li><code>GET /platform/complaint/list</code> — 分页。Query:<code>page</code>、<code>pageSize</code>、<code>categoryId</code>、<code>status</code>(0~3)、<code>keyword</code>。</li>
|
||||
<li><code>GET /platform/complaint/:id</code> — 详情。</li>
|
||||
<li><code>POST /platform/complaint/:id</code> — 更新状态、回复等:<code>status</code>、<code>replyContent</code>、<code>remark</code>、<code>categoryId</code> 等。</li>
|
||||
<li><code>DELETE /platform/complaint/:id</code> — 软删。</li>
|
||||
</ul>
|
||||
<p><strong>状态</strong>:<code>0</code> 待处理 · <code>1</code> 处理中 · <code>2</code> 已回复 · <code>3</code> 已关闭</p>
|
||||
|
||||
<h4>五、数据表</h4>
|
||||
<p><code>yz_system_complaint_category</code>、<code>yz_system_platform_complaint</code></p>
|
||||
</div>
|
||||
</el-scrollbar>
|
||||
</el-drawer>
|
||||
|
||||
<!-- 产品分类管理 -->
|
||||
<el-dialog v-model="categoryVisible" title="投诉建议 — 产品分类" width="640px" destroy-on-close>
|
||||
<div class="cat-toolbar">
|
||||
<el-button type="primary" size="small" @click="openCatForm()">新增分类</el-button>
|
||||
<el-button size="small" @click="loadCategoryList">刷新</el-button>
|
||||
</div>
|
||||
<el-table :data="categoryRows" v-loading="catLoading" border size="small" style="margin-top: 12px">
|
||||
<el-table-column prop="id" label="ID" width="64" align="center" />
|
||||
<el-table-column prop="name" label="名称" min-width="120" />
|
||||
<el-table-column prop="code" label="编码" width="100" />
|
||||
<el-table-column prop="sort" label="排序" width="72" align="center" />
|
||||
<el-table-column prop="status" label="状态" width="80" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.status === 1 ? 'success' : 'info'">{{ row.status === 1 ? "启用" : "禁用" }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="140" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-button text type="primary" size="small" @click="openCatForm(row)">编辑</el-button>
|
||||
<el-button text type="danger" size="small" @click="handleCatDelete(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog
|
||||
v-model="catFormVisible"
|
||||
:title="catForm.id ? '编辑分类' : '新增分类'"
|
||||
width="420px"
|
||||
append-to-body
|
||||
>
|
||||
<el-form :model="catForm" label-width="80px">
|
||||
<el-form-item label="名称" required>
|
||||
<el-input v-model="catForm.name" placeholder="如:官网、租户后台" />
|
||||
</el-form-item>
|
||||
<el-form-item label="编码">
|
||||
<el-input v-model="catForm.code" placeholder="可选" />
|
||||
</el-form-item>
|
||||
<el-form-item label="排序">
|
||||
<el-input-number v-model="catForm.sort" :min="0" style="width: 100%" />
|
||||
</el-form-item>
|
||||
<el-form-item label="状态">
|
||||
<el-radio-group v-model="catForm.status">
|
||||
<el-radio-button :value="1">启用</el-radio-button>
|
||||
<el-radio-button :value="0">禁用</el-radio-button>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="catFormVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="catSaving" @click="saveCategory">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from "vue";
|
||||
import { ElMessage, ElMessageBox } from "element-plus";
|
||||
import { Plus, Refresh, Collection, Document } from "@element-plus/icons-vue";
|
||||
import {
|
||||
getComplaintList,
|
||||
deleteComplaint,
|
||||
getComplaintCategoryList,
|
||||
createComplaintCategory,
|
||||
updateComplaintCategory,
|
||||
deleteComplaintCategory,
|
||||
} from "@/api/complaint";
|
||||
import ComplaintEdit from "./components/edit.vue";
|
||||
|
||||
const editRef = ref(null);
|
||||
const apiDocVisible = ref(false);
|
||||
const apiBase = import.meta.env.VITE_API_BASE_URL || "";
|
||||
const loading = ref(false);
|
||||
const list = ref([]);
|
||||
const categoryFilterOptions = ref([]);
|
||||
|
||||
const searchForm = reactive({
|
||||
categoryId: null,
|
||||
status: null,
|
||||
keyword: "",
|
||||
});
|
||||
|
||||
const pagination = reactive({
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
total: 0,
|
||||
});
|
||||
|
||||
const categoryVisible = ref(false);
|
||||
const catLoading = ref(false);
|
||||
const categoryRows = ref([]);
|
||||
const catFormVisible = ref(false);
|
||||
const catSaving = ref(false);
|
||||
const catForm = reactive({
|
||||
id: 0,
|
||||
name: "",
|
||||
code: "",
|
||||
sort: 0,
|
||||
status: 1,
|
||||
});
|
||||
|
||||
function statusText(s) {
|
||||
const m = { 0: "待处理", 1: "处理中", 2: "已回复", 3: "已关闭" };
|
||||
return m[s] ?? String(s);
|
||||
}
|
||||
|
||||
function statusTag(s) {
|
||||
if (s === 2) return "success";
|
||||
if (s === 3) return "info";
|
||||
if (s === 1) return "warning";
|
||||
return "";
|
||||
}
|
||||
|
||||
async function loadCategoryFilter() {
|
||||
const res = await getComplaintCategoryList();
|
||||
if (res?.code === 200 && Array.isArray(res.data)) {
|
||||
categoryFilterOptions.value = res.data;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchList() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const params = {
|
||||
page: pagination.page,
|
||||
pageSize: pagination.pageSize,
|
||||
keyword: searchForm.keyword || undefined,
|
||||
};
|
||||
if (searchForm.categoryId != null && searchForm.categoryId !== "") {
|
||||
params.categoryId = searchForm.categoryId;
|
||||
}
|
||||
if (searchForm.status !== null && searchForm.status !== "") {
|
||||
params.status = searchForm.status;
|
||||
}
|
||||
const res = await getComplaintList(params);
|
||||
if (res?.code === 200 && res.data) {
|
||||
list.value = res.data.list || [];
|
||||
pagination.total = res.data.total ?? 0;
|
||||
} else {
|
||||
list.value = [];
|
||||
ElMessage.error(res?.msg || "加载失败");
|
||||
}
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
pagination.page = 1;
|
||||
fetchList();
|
||||
}
|
||||
|
||||
function resetSearch() {
|
||||
searchForm.categoryId = null;
|
||||
searchForm.status = null;
|
||||
searchForm.keyword = "";
|
||||
pagination.page = 1;
|
||||
fetchList();
|
||||
}
|
||||
|
||||
async function handleDelete(row) {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定删除「${row.title}」吗?`, "提示", { type: "warning" });
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
const res = await deleteComplaint(row.id);
|
||||
if (res?.code === 200) {
|
||||
ElMessage.success("已删除");
|
||||
fetchList();
|
||||
} else {
|
||||
ElMessage.error(res?.msg || "删除失败");
|
||||
}
|
||||
}
|
||||
|
||||
function openCategoryDialog() {
|
||||
categoryVisible.value = true;
|
||||
loadCategoryList();
|
||||
}
|
||||
|
||||
async function loadCategoryList() {
|
||||
catLoading.value = true;
|
||||
try {
|
||||
const res = await getComplaintCategoryList();
|
||||
if (res?.code === 200 && Array.isArray(res.data)) {
|
||||
categoryRows.value = res.data;
|
||||
}
|
||||
} finally {
|
||||
catLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function openCatForm(row) {
|
||||
if (row) {
|
||||
catForm.id = row.id;
|
||||
catForm.name = row.name || "";
|
||||
catForm.code = row.code || "";
|
||||
catForm.sort = row.sort ?? 0;
|
||||
catForm.status = row.status ?? 1;
|
||||
} else {
|
||||
catForm.id = 0;
|
||||
catForm.name = "";
|
||||
catForm.code = "";
|
||||
catForm.sort = 0;
|
||||
catForm.status = 1;
|
||||
}
|
||||
catFormVisible.value = true;
|
||||
}
|
||||
|
||||
async function saveCategory() {
|
||||
if (!catForm.name.trim()) {
|
||||
ElMessage.warning("请填写名称");
|
||||
return;
|
||||
}
|
||||
catSaving.value = true;
|
||||
try {
|
||||
const body = {
|
||||
name: catForm.name.trim(),
|
||||
code: catForm.code?.trim() || undefined,
|
||||
sort: catForm.sort,
|
||||
status: catForm.status,
|
||||
};
|
||||
let res;
|
||||
if (catForm.id) {
|
||||
res = await updateComplaintCategory(catForm.id, body);
|
||||
} else {
|
||||
res = await createComplaintCategory(body);
|
||||
}
|
||||
if (res?.code === 200) {
|
||||
ElMessage.success("已保存");
|
||||
catFormVisible.value = false;
|
||||
await loadCategoryList();
|
||||
await loadCategoryFilter();
|
||||
} else {
|
||||
ElMessage.error(res?.msg || "失败");
|
||||
}
|
||||
} finally {
|
||||
catSaving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCatDelete(row) {
|
||||
try {
|
||||
await ElMessageBox.confirm(`删除分类「${row.name}」?已有投诉引用时请谨慎。`, "提示", { type: "warning" });
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
const res = await deleteComplaintCategory(row.id);
|
||||
if (res?.code === 200) {
|
||||
ElMessage.success("已删除");
|
||||
loadCategoryList();
|
||||
loadCategoryFilter();
|
||||
} else {
|
||||
ElMessage.error(res?.msg || "删除失败");
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadCategoryFilter();
|
||||
fetchList();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.container-box {
|
||||
padding: 16px;
|
||||
}
|
||||
.header-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
.search-form {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.pager {
|
||||
margin-top: 16px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.cat-toolbar {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.api-doc {
|
||||
padding-right: 12px;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
color: var(--el-text-color-regular);
|
||||
}
|
||||
.api-doc h4 {
|
||||
margin: 18px 0 8px;
|
||||
font-size: 14px;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
.api-doc ul {
|
||||
margin: 8px 0;
|
||||
padding-left: 1.2em;
|
||||
}
|
||||
.api-doc code {
|
||||
font-size: 12px;
|
||||
padding: 0 4px;
|
||||
background: var(--el-fill-color-light);
|
||||
border-radius: 4px;
|
||||
}
|
||||
.api-pre {
|
||||
margin: 8px 0;
|
||||
padding: 10px 12px;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
background: var(--el-fill-color-light);
|
||||
border-radius: 6px;
|
||||
overflow-x: auto;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
.api-doc-lead {
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
.api-tip {
|
||||
margin-top: 8px;
|
||||
padding: 8px 10px;
|
||||
font-size: 12px;
|
||||
background: var(--el-color-info-light-9);
|
||||
border-radius: 6px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
</style>
|
||||
11
src/views/platform/index.vue
Normal file
11
src/views/platform/index.vue
Normal file
@ -0,0 +1,11 @@
|
||||
<script setup>
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<router-view />
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
463
src/views/platform/softwareupgrade/components/edit.vue
Normal file
463
src/views/platform/softwareupgrade/components/edit.vue
Normal file
@ -0,0 +1,463 @@
|
||||
<template>
|
||||
<el-drawer
|
||||
v-model="visible"
|
||||
:title="isAdd ? '新增软件产品' : '编辑软件产品'"
|
||||
size="560px"
|
||||
destroy-on-close
|
||||
@closed="onClosed"
|
||||
>
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-width="110px" v-loading="loading">
|
||||
<el-form-item label="软件名称" prop="name">
|
||||
<el-input v-model="form.name" placeholder="如:桌面客户端" />
|
||||
</el-form-item>
|
||||
<el-form-item label="产品 code" prop="code">
|
||||
<el-input
|
||||
v-model="form.code"
|
||||
placeholder="客户端检查更新时传的 code,唯一"
|
||||
:disabled="!isAdd"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="最新版本号" prop="latestVersion">
|
||||
<el-input v-model="form.latestVersion" placeholder="如 1.2.0" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="安装包">
|
||||
<el-upload
|
||||
ref="uploadRef"
|
||||
class="pkg-upload"
|
||||
:limit="1"
|
||||
:file-list="displayFileList"
|
||||
:http-request="handlePackageUpload"
|
||||
:on-remove="onRemovePackage"
|
||||
@exceed="onExceedReplace"
|
||||
accept=".zip,.exe,.dmg,.msi,.msix,.apk,.deb,.rpm,.7z,.pkg,.tar,.gz"
|
||||
>
|
||||
<el-button type="primary" :loading="uploading">
|
||||
{{ form.fileId ? "重新上传安装包" : "上传安装包" }}
|
||||
</el-button>
|
||||
</el-upload>
|
||||
<div v-if="uploadXHRActive" class="upload-progress-wrap">
|
||||
<el-progress
|
||||
:percentage="uploadPercent"
|
||||
:indeterminate="uploadIndeterminate"
|
||||
:stroke-width="10"
|
||||
striped
|
||||
striped-flow
|
||||
/>
|
||||
<div class="upload-progress-text">
|
||||
<template v-if="!uploadIndeterminate">
|
||||
<span>{{ uploadPercent }}%</span>
|
||||
<span class="upload-sep">·</span>
|
||||
<span>{{ uploadSpeedText }}</span>
|
||||
<template v-if="uploadSizeText">
|
||||
<span class="upload-sep">·</span>
|
||||
<span>{{ uploadSizeText }}</span>
|
||||
</template>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span>上传中…</span>
|
||||
<template v-if="uploadSpeedText && uploadSpeedText !== '—'">
|
||||
<span class="upload-sep">·</span>
|
||||
<span>{{ uploadSpeedText }}</span>
|
||||
</template>
|
||||
<template v-if="uploadSizeText">
|
||||
<span class="upload-sep">·</span>
|
||||
<span>{{ uploadSizeText }}</span>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="form.fileId" class="pkg-meta">
|
||||
<span>文件 ID:<code>{{ form.fileId }}</code></span>
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="下载/更新地址">
|
||||
<el-input
|
||||
v-model="form.downloadUrl"
|
||||
type="textarea"
|
||||
:rows="2"
|
||||
placeholder="上传后自动填写;可改为 CDN 等完整 URL"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="强制更新">
|
||||
<el-switch v-model="form.forceUpdate" :active-value="1" :inactive-value="0" />
|
||||
</el-form-item>
|
||||
<el-form-item label="更新说明">
|
||||
<el-input v-model="form.releaseNotes" type="textarea" :rows="4" />
|
||||
</el-form-item>
|
||||
<el-form-item label="状态">
|
||||
<el-radio-group v-model="form.status">
|
||||
<el-radio-button :value="1">启用</el-radio-button>
|
||||
<el-radio-button :value="0">停用</el-radio-button>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item label="排序">
|
||||
<el-input-number v-model="form.sort" :min="0" style="width: 100%" />
|
||||
</el-form-item>
|
||||
<el-form-item v-if="!isAdd && resolvedUrl" label="当前解析地址">
|
||||
<el-link :href="resolvedUrl" type="primary" target="_blank">{{ resolvedUrl }}</el-link>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="visible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="saving" @click="submit">保存</el-button>
|
||||
</template>
|
||||
</el-drawer>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, nextTick } from "vue";
|
||||
import { ElMessage } from "element-plus";
|
||||
import {
|
||||
getSoftwareUpgradeDetail,
|
||||
createSoftwareUpgrade,
|
||||
updateSoftwareUpgrade,
|
||||
} from "@/api/softwareUpgrade";
|
||||
import { getUserCate, createFileCate, uploadFile, getFileById } from "@/api/file";
|
||||
|
||||
const emit = defineEmits(["saved"]);
|
||||
|
||||
const visible = ref(false);
|
||||
const loading = ref(false);
|
||||
const saving = ref(false);
|
||||
const uploading = ref(false);
|
||||
const uploadXHRActive = ref(false);
|
||||
const uploadPercent = ref(0);
|
||||
const uploadIndeterminate = ref(false);
|
||||
const uploadSpeedText = ref("—");
|
||||
const uploadSizeText = ref("");
|
||||
const isAdd = ref(false);
|
||||
const resolvedUrl = ref("");
|
||||
const formRef = ref(null);
|
||||
const uploadRef = ref(null);
|
||||
const displayFileList = ref([]);
|
||||
const uploadedLabel = ref("");
|
||||
const appsUpgradeCateId = ref(null);
|
||||
|
||||
const form = reactive({
|
||||
id: 0,
|
||||
name: "",
|
||||
code: "",
|
||||
latestVersion: "0.0.1",
|
||||
fileId: null,
|
||||
downloadUrl: "",
|
||||
forceUpdate: 0,
|
||||
releaseNotes: "",
|
||||
status: 1,
|
||||
sort: 0,
|
||||
});
|
||||
|
||||
const rules = {
|
||||
name: [{ required: true, message: "请输入名称", trigger: "blur" }],
|
||||
code: [{ required: true, message: "请输入产品标识", trigger: "blur" }],
|
||||
latestVersion: [{ required: true, message: "请输入版本号", trigger: "blur" }],
|
||||
};
|
||||
|
||||
function formatBytes(n) {
|
||||
if (n == null || !Number.isFinite(n) || n < 0) return "";
|
||||
if (n < 1024) return `${Math.round(n)} B`;
|
||||
if (n < 1024 * 1024) return `${(n / 1024).toFixed(n < 10240 ? 2 : 1)} KB`;
|
||||
if (n < 1024 * 1024 * 1024) return `${(n / (1024 * 1024)).toFixed(2)} MB`;
|
||||
return `${(n / (1024 * 1024 * 1024)).toFixed(2)} GB`;
|
||||
}
|
||||
|
||||
/** @param {number} bps bytes per second */
|
||||
function formatSpeed(bps) {
|
||||
if (bps == null || !Number.isFinite(bps) || bps <= 0) return "—";
|
||||
return `${formatBytes(bps)}/s`;
|
||||
}
|
||||
|
||||
function absoluteFromSrc(src) {
|
||||
if (!src) return "";
|
||||
const s = String(src).trim();
|
||||
if (/^https?:\/\//i.test(s)) return s;
|
||||
const base = (import.meta.env.VITE_API_BASE_URL || "").replace(/\/$/, "");
|
||||
const path = s.startsWith("/") ? s : `/${s}`;
|
||||
return base ? `${base}${path}` : path;
|
||||
}
|
||||
|
||||
async function ensureAppsUpgradeCate() {
|
||||
if (appsUpgradeCateId.value != null) {
|
||||
return appsUpgradeCateId.value;
|
||||
}
|
||||
const res = await getUserCate();
|
||||
if (res?.code !== 200 || !Array.isArray(res.data)) {
|
||||
return null;
|
||||
}
|
||||
const found = res.data.find(
|
||||
(c) => String(c.name || "").trim().toLowerCase() === "appsupgrade"
|
||||
);
|
||||
if (found && found.id) {
|
||||
appsUpgradeCateId.value = Number(found.id);
|
||||
return appsUpgradeCateId.value;
|
||||
}
|
||||
const cr = await createFileCate({ name: "appsupgrade" });
|
||||
if (cr?.code === 200 && cr.data?.id != null) {
|
||||
appsUpgradeCateId.value = Number(cr.data.id);
|
||||
return appsUpgradeCateId.value;
|
||||
}
|
||||
ElMessage.error(cr?.msg || "创建文件分组 appsupgrade 失败");
|
||||
return null;
|
||||
}
|
||||
|
||||
function reset() {
|
||||
form.id = 0;
|
||||
form.name = "";
|
||||
form.code = "";
|
||||
form.latestVersion = "0.0.1";
|
||||
form.fileId = null;
|
||||
form.downloadUrl = "";
|
||||
form.forceUpdate = 0;
|
||||
form.releaseNotes = "";
|
||||
form.status = 1;
|
||||
form.sort = 0;
|
||||
resolvedUrl.value = "";
|
||||
displayFileList.value = [];
|
||||
uploadedLabel.value = "";
|
||||
uploadXHRActive.value = false;
|
||||
uploadPercent.value = 0;
|
||||
uploadIndeterminate.value = false;
|
||||
uploadSpeedText.value = "—";
|
||||
uploadSizeText.value = "";
|
||||
uploadRef.value?.clearFiles?.();
|
||||
}
|
||||
|
||||
function onClosed() {
|
||||
reset();
|
||||
}
|
||||
|
||||
function onRemovePackage() {
|
||||
form.fileId = null;
|
||||
form.downloadUrl = "";
|
||||
uploadedLabel.value = "";
|
||||
displayFileList.value = [];
|
||||
}
|
||||
|
||||
/** Element Plus:超出 limit 时传入待加入的文件列表(一般为 File[]) */
|
||||
function onExceedReplace(files) {
|
||||
const first = files[0];
|
||||
const raw = first?.raw != null ? first.raw : first;
|
||||
uploadRef.value?.clearFiles?.();
|
||||
form.fileId = null;
|
||||
form.downloadUrl = "";
|
||||
uploadedLabel.value = "";
|
||||
displayFileList.value = [];
|
||||
if (raw) {
|
||||
handlePackageUpload({ file: raw, onSuccess: () => {}, onError: () => {} });
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePackageUpload(options) {
|
||||
const file = options.file?.raw != null ? options.file.raw : options.file;
|
||||
if (!file) {
|
||||
options.onError?.(new Error("无文件"));
|
||||
return;
|
||||
}
|
||||
uploading.value = true;
|
||||
try {
|
||||
const cateId = await ensureAppsUpgradeCate();
|
||||
if (!cateId) {
|
||||
options.onError?.(new Error("no category"));
|
||||
return;
|
||||
}
|
||||
const fd = new FormData();
|
||||
fd.append("file", file);
|
||||
uploadXHRActive.value = true;
|
||||
uploadPercent.value = 0;
|
||||
uploadIndeterminate.value = false;
|
||||
uploadSpeedText.value = "—";
|
||||
uploadSizeText.value = "";
|
||||
const xhrStart = Date.now();
|
||||
let lastLoaded = 0;
|
||||
let lastTick = xhrStart;
|
||||
let emaBps = 0;
|
||||
const res = await uploadFile(fd, {
|
||||
cate: cateId,
|
||||
onUploadProgress: (e) => {
|
||||
const now = Date.now();
|
||||
const loaded = e.loaded;
|
||||
const total = e.total ?? 0;
|
||||
const elapsedSec = (now - xhrStart) / 1000;
|
||||
const dt = (now - lastTick) / 1000;
|
||||
if (dt >= 0.07 && loaded >= lastLoaded) {
|
||||
const inst = (loaded - lastLoaded) / dt;
|
||||
emaBps = emaBps > 0 ? emaBps * 0.72 + inst * 0.28 : inst;
|
||||
lastLoaded = loaded;
|
||||
lastTick = now;
|
||||
}
|
||||
const avgBps = elapsedSec > 0.12 ? loaded / elapsedSec : 0;
|
||||
const showBps = emaBps > 0 ? emaBps : avgBps;
|
||||
uploadSpeedText.value = showBps > 0 ? formatSpeed(showBps) : elapsedSec > 0.05 ? formatSpeed(avgBps) : "—";
|
||||
|
||||
if (total > 0) {
|
||||
uploadIndeterminate.value = false;
|
||||
uploadPercent.value = Math.min(100, Math.round((loaded * 100) / total));
|
||||
uploadSizeText.value = `${formatBytes(loaded)} / ${formatBytes(total)}`;
|
||||
} else {
|
||||
uploadIndeterminate.value = true;
|
||||
uploadSizeText.value = loaded > 0 ? `已上传 ${formatBytes(loaded)}` : "";
|
||||
}
|
||||
},
|
||||
});
|
||||
if (res?.code === 200 || res?.code === 201) {
|
||||
const d = res.data || {};
|
||||
form.fileId = d.id != null ? Number(d.id) : null;
|
||||
const src = d.url || "";
|
||||
form.downloadUrl = absoluteFromSrc(src);
|
||||
uploadedLabel.value = d.name || file.name || "安装包";
|
||||
displayFileList.value = [{ name: uploadedLabel.value, uid: `pkg-${form.fileId}-${Date.now()}` }];
|
||||
ElMessage.success(res.code === 201 ? "文件已存在,已关联" : "上传成功");
|
||||
options.onSuccess?.(res);
|
||||
} else {
|
||||
ElMessage.error(res?.msg || "上传失败");
|
||||
options.onError?.(new Error(res?.msg));
|
||||
}
|
||||
} catch (e) {
|
||||
ElMessage.error(e?.message || "上传失败");
|
||||
options.onError?.(e);
|
||||
} finally {
|
||||
uploading.value = false;
|
||||
uploadXHRActive.value = false;
|
||||
uploadPercent.value = 0;
|
||||
uploadIndeterminate.value = false;
|
||||
uploadSpeedText.value = "—";
|
||||
uploadSizeText.value = "";
|
||||
}
|
||||
}
|
||||
|
||||
async function open(id) {
|
||||
reset();
|
||||
isAdd.value = !id;
|
||||
visible.value = true;
|
||||
appsUpgradeCateId.value = null;
|
||||
await nextTick();
|
||||
formRef.value?.clearValidate?.();
|
||||
if (!id) return;
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await getSoftwareUpgradeDetail(id);
|
||||
if (res?.code !== 200 || !res.data) {
|
||||
ElMessage.error(res?.msg || "加载失败");
|
||||
visible.value = false;
|
||||
return;
|
||||
}
|
||||
const d = res.data;
|
||||
form.id = d.id;
|
||||
form.name = d.name || "";
|
||||
form.code = d.code || "";
|
||||
form.latestVersion = d.latestVersion || "0.0.1";
|
||||
form.fileId = d.fileId != null ? Number(d.fileId) : null;
|
||||
form.downloadUrl = d.downloadUrl || d.resolvedDownloadUrl || "";
|
||||
form.forceUpdate = d.forceUpdate ? 1 : 0;
|
||||
form.releaseNotes = d.releaseNotes || "";
|
||||
form.status = d.status !== 0 ? 1 : 0;
|
||||
form.sort = d.sort ?? 0;
|
||||
resolvedUrl.value = d.resolvedDownloadUrl || "";
|
||||
|
||||
if (form.fileId) {
|
||||
try {
|
||||
const fr = await getFileById(form.fileId);
|
||||
if (fr?.code === 200 && fr.data) {
|
||||
uploadedLabel.value = fr.data.name || `文件 #${form.fileId}`;
|
||||
} else {
|
||||
uploadedLabel.value = `文件 #${form.fileId}`;
|
||||
}
|
||||
} catch {
|
||||
uploadedLabel.value = `文件 #${form.fileId}`;
|
||||
}
|
||||
displayFileList.value = [
|
||||
{ name: uploadedLabel.value, uid: `exist-${form.fileId}` },
|
||||
];
|
||||
}
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
try {
|
||||
await formRef.value?.validate();
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
const payload = {
|
||||
name: form.name.trim(),
|
||||
code: form.code.trim(),
|
||||
latestVersion: form.latestVersion.trim(),
|
||||
downloadUrl: form.downloadUrl.trim() || undefined,
|
||||
forceUpdate: form.forceUpdate,
|
||||
releaseNotes: form.releaseNotes.trim() || undefined,
|
||||
status: form.status,
|
||||
sort: form.sort,
|
||||
};
|
||||
if (form.fileId != null && form.fileId !== "" && Number(form.fileId) > 0) {
|
||||
payload.fileId = Number(form.fileId);
|
||||
} else {
|
||||
payload.fileId = 0;
|
||||
}
|
||||
|
||||
saving.value = true;
|
||||
try {
|
||||
let res;
|
||||
if (isAdd.value) {
|
||||
res = await createSoftwareUpgrade(payload);
|
||||
} else {
|
||||
res = await updateSoftwareUpgrade(form.id, payload);
|
||||
}
|
||||
if (res?.code === 200) {
|
||||
ElMessage.success("已保存");
|
||||
visible.value = false;
|
||||
emit("saved");
|
||||
} else {
|
||||
ElMessage.error(res?.msg || "失败");
|
||||
}
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({ open });
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.hint {
|
||||
margin: 0 0 10px;
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
line-height: 1.55;
|
||||
}
|
||||
.hint code {
|
||||
padding: 0 4px;
|
||||
background: var(--el-fill-color-light);
|
||||
border-radius: 4px;
|
||||
}
|
||||
.pkg-upload {
|
||||
width: 100%;
|
||||
}
|
||||
.upload-progress-wrap {
|
||||
margin-top: 10px;
|
||||
width: 100%;
|
||||
}
|
||||
.upload-progress-text {
|
||||
display: block;
|
||||
margin-top: 6px;
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
line-height: 1.5;
|
||||
word-break: break-all;
|
||||
}
|
||||
.upload-progress-text .upload-sep {
|
||||
margin: 0 0.35em;
|
||||
opacity: 0.55;
|
||||
}
|
||||
.pkg-meta {
|
||||
margin-top: 8px;
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
.pkg-meta code {
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
260
src/views/platform/softwareupgrade/index.vue
Normal file
260
src/views/platform/softwareupgrade/index.vue
Normal file
@ -0,0 +1,260 @@
|
||||
<template>
|
||||
<div class="container-box">
|
||||
<div class="header-bar">
|
||||
<h2>软件升级</h2>
|
||||
<div class="header-actions">
|
||||
<el-button @click="apiDocVisible = true">
|
||||
<el-icon><Document /></el-icon>
|
||||
接口说明
|
||||
</el-button>
|
||||
<el-button type="primary" @click="editRef.open()">
|
||||
<el-icon><Plus /></el-icon>
|
||||
新增产品
|
||||
</el-button>
|
||||
<el-button @click="fetchList" :loading="loading">
|
||||
<el-icon><Refresh /></el-icon>
|
||||
刷新
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-divider />
|
||||
|
||||
<el-form :inline="true" :model="searchForm" class="search-form">
|
||||
<el-form-item label="关键词">
|
||||
<el-input
|
||||
v-model="searchForm.keyword"
|
||||
placeholder="名称 / 产品标识"
|
||||
clearable
|
||||
style="width: 220px"
|
||||
@keyup.enter="handleSearch"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="handleSearch">查询</el-button>
|
||||
<el-button @click="resetSearch">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<el-table :data="list" v-loading="loading" border style="width: 100%">
|
||||
<el-table-column prop="id" label="ID" width="72" align="center" />
|
||||
<el-table-column prop="name" label="软件名称" min-width="140" />
|
||||
<el-table-column prop="code" label="产品code" width="140" />
|
||||
<el-table-column prop="latestVersion" label="最新版本" width="100" align="center" />
|
||||
<el-table-column label="文件 ID" width="88" align="center">
|
||||
<template #default="{ row }">{{ row.fileId ?? "—" }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="解析下载地址" min-width="200" show-overflow-tooltip>
|
||||
<template #default="{ row }">
|
||||
<el-link v-if="row.resolvedDownloadUrl" :href="row.resolvedDownloadUrl" type="primary" target="_blank">
|
||||
{{ row.resolvedDownloadUrl }}
|
||||
</el-link>
|
||||
<span v-else class="muted">—</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="forceUpdate" label="强制更新" width="90" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.forceUpdate ? 'danger' : 'info'">{{ row.forceUpdate ? "是" : "否" }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="status" label="状态" width="80" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.status === 1 ? 'success' : 'info'">{{ row.status === 1 ? "启用" : "停用" }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="sort" label="排序" width="72" align="center" />
|
||||
<el-table-column label="操作" width="140" align="center" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button text type="primary" size="small" @click="editRef.open(row.id)">编辑</el-button>
|
||||
<el-button text type="danger" size="small" @click="handleDelete(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div class="pager">
|
||||
<el-pagination
|
||||
v-model:current-page="pagination.page"
|
||||
v-model:page-size="pagination.pageSize"
|
||||
:total="pagination.total"
|
||||
:page-sizes="[10, 20, 50]"
|
||||
layout="total, sizes, prev, pager, next"
|
||||
@current-change="fetchList"
|
||||
@size-change="fetchList"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<SoftwareEdit ref="editRef" @saved="fetchList" />
|
||||
|
||||
<el-drawer v-model="apiDocVisible" title="软件升级 — 接口说明" direction="rtl" size="540px">
|
||||
<el-scrollbar max-height="calc(100vh - 120px)">
|
||||
<div class="api-doc">
|
||||
<p class="lead">客户端启动时调用开放接口比对版本;管理端接口需平台管理员 Token。</p>
|
||||
<p><strong>API 根地址</strong></p>
|
||||
<pre class="api-pre">{{ apiBase }}</pre>
|
||||
|
||||
<h4>一、客户端检查更新(无需登录)</h4>
|
||||
<p><code>GET /api/softwareupgrade/check</code></p>
|
||||
<p>Query:</p>
|
||||
<ul>
|
||||
<li><code>code</code>(必填):与后台配置的「产品标识」一致</li>
|
||||
<li><code>version</code>(可选):当前客户端版本,缺省按 <code>0.0.0</code></li>
|
||||
</ul>
|
||||
<p>响应 <code>data</code> 字段说明:</p>
|
||||
<ul>
|
||||
<li><code>upToDate</code>:是否已是最新(当前版本 ≥ 后台最新版本)</li>
|
||||
<li><code>latestVersion</code>:后台配置的最新版本号</li>
|
||||
<li><code>downloadUrl</code>:安装包地址(自定义 URL 或由文件 ID 解析)</li>
|
||||
<li><code>forceUpdate</code>:是否建议强制更新</li>
|
||||
<li><code>releaseNotes</code>:更新说明</li>
|
||||
</ul>
|
||||
<pre class="api-pre">GET {{ apiBase }}/api/softwareupgrade/check?code=my-app&version=1.0.0</pre>
|
||||
|
||||
<h4>二、版本号规则</h4>
|
||||
<p>按 <code>.</code> 分段数字比较(如 <code>1.10.0</code> > <code>1.9.0</code>)。后缀 <code>-beta</code> 等仅截取 <code>-</code> 前参与比较。</p>
|
||||
|
||||
<h4>三、安装包上传</h4>
|
||||
<p>在「软件升级」编辑抽屉内点击 <strong>上传安装包</strong>,会调用 <code>POST /platform/uploadfile</code>,并自动归入文件分组 <code>appsupgrade</code>(不存在则自动创建)。保存后会把 <strong>file_id</strong> 与根据 <code>VITE_API_BASE_URL</code> 拼好的 <strong>download_url</strong> 写入数据库。</p>
|
||||
<p>静态资源映射:<code>/uploads</code>;生产环境请配置正确的 API 公网地址与反向代理头(<code>X-Forwarded-Proto</code> 等)。</p>
|
||||
|
||||
<h4>四、管理端接口(需 Bearer)</h4>
|
||||
<ul>
|
||||
<li><code>GET /platform/softwareupgrade/list</code> — <code>page</code>、<code>pageSize</code>、<code>keyword</code></li>
|
||||
<li><code>GET /platform/softwareupgrade/:id</code></li>
|
||||
<li><code>POST /platform/softwareupgrade</code> — 新增</li>
|
||||
<li><code>POST /platform/softwareupgrade/:id</code> — 更新</li>
|
||||
<li><code>DELETE /platform/softwareupgrade/:id</code> — 软删</li>
|
||||
</ul>
|
||||
<p>表:<code>yz_system_software_upgrade</code></p>
|
||||
</div>
|
||||
</el-scrollbar>
|
||||
</el-drawer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from "vue";
|
||||
import { ElMessage, ElMessageBox } from "element-plus";
|
||||
import { Plus, Refresh, Document } from "@element-plus/icons-vue";
|
||||
import { getSoftwareUpgradeList, deleteSoftwareUpgrade } from "@/api/softwareUpgrade";
|
||||
import SoftwareEdit from "./components/edit.vue";
|
||||
|
||||
const editRef = ref(null);
|
||||
const apiDocVisible = ref(false);
|
||||
const apiBase = import.meta.env.VITE_API_BASE_URL || "";
|
||||
const loading = ref(false);
|
||||
const list = ref([]);
|
||||
|
||||
const searchForm = reactive({ keyword: "" });
|
||||
const pagination = reactive({ page: 1, pageSize: 20, total: 0 });
|
||||
|
||||
async function fetchList() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await getSoftwareUpgradeList({
|
||||
page: pagination.page,
|
||||
pageSize: pagination.pageSize,
|
||||
keyword: searchForm.keyword || undefined,
|
||||
});
|
||||
if (res?.code === 200 && res.data) {
|
||||
list.value = res.data.list || [];
|
||||
pagination.total = res.data.total ?? 0;
|
||||
} else {
|
||||
list.value = [];
|
||||
ElMessage.error(res?.msg || "加载失败");
|
||||
}
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
pagination.page = 1;
|
||||
fetchList();
|
||||
}
|
||||
|
||||
function resetSearch() {
|
||||
searchForm.keyword = "";
|
||||
pagination.page = 1;
|
||||
fetchList();
|
||||
}
|
||||
|
||||
async function handleDelete(row) {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定删除「${row.name}」吗?`, "提示", { type: "warning" });
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
const res = await deleteSoftwareUpgrade(row.id);
|
||||
if (res?.code === 200) {
|
||||
ElMessage.success("已删除");
|
||||
fetchList();
|
||||
} else {
|
||||
ElMessage.error(res?.msg || "删除失败");
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchList();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.container-box {
|
||||
padding: 16px;
|
||||
}
|
||||
.header-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
.search-form {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.pager {
|
||||
margin-top: 16px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.muted {
|
||||
color: var(--el-text-color-placeholder);
|
||||
}
|
||||
.api-doc {
|
||||
padding-right: 12px;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
color: var(--el-text-color-regular);
|
||||
}
|
||||
.api-doc h4 {
|
||||
margin: 18px 0 8px;
|
||||
font-size: 14px;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
.api-doc ul {
|
||||
margin: 8px 0;
|
||||
padding-left: 1.2em;
|
||||
}
|
||||
.api-doc code {
|
||||
font-size: 12px;
|
||||
padding: 0 4px;
|
||||
background: var(--el-fill-color-light);
|
||||
border-radius: 4px;
|
||||
}
|
||||
.api-pre {
|
||||
margin: 8px 0;
|
||||
padding: 10px 12px;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
background: var(--el-fill-color-light);
|
||||
border-radius: 6px;
|
||||
overflow-x: auto;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
.lead {
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
</style>
|
||||
@ -7,6 +7,11 @@ import { ElementPlusResolver } from "unplugin-vue-components/resolvers";
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
// Windows 下若 dist 被占用,清空或覆盖会 EPERM;产物默认写到 output/(与 dist 分离)。部署时请同步指向 output。
|
||||
build: {
|
||||
outDir: "output",
|
||||
emptyOutDir: true,
|
||||
},
|
||||
plugins: [
|
||||
vue(),
|
||||
AutoImport({
|
||||
@ -23,10 +28,14 @@ export default defineConfig({
|
||||
},
|
||||
server: {
|
||||
port: 5000,
|
||||
// 开发时前端在 5000,接口走相对路径 /platform/*,由这里转发到 Go(go/conf/app.conf 默认 httpport=8080)
|
||||
// 开发时前端在 5000,接口走相对路径 /platform/*、/backend/*,转发到本地 Go(当前 httpport=8081)
|
||||
proxy: {
|
||||
"/platform": {
|
||||
target: "http://127.0.0.1:8080",
|
||||
target: "http://127.0.0.1:8081",
|
||||
changeOrigin: true,
|
||||
},
|
||||
"/backend": {
|
||||
target: "http://127.0.0.1:8081",
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
|
||||
Loading…
Reference in New Issue
Block a user