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
|
node_modules
|
||||||
dist
|
dist
|
||||||
|
output
|
||||||
dist-ssr
|
dist-ssr
|
||||||
*.local
|
*.local
|
||||||
|
output.zip
|
||||||
|
dist.zip
|
||||||
|
dist.7z
|
||||||
|
output.7z
|
||||||
|
|
||||||
# Editor directories and files
|
# Editor directories and files
|
||||||
.vscode/*
|
.vscode/*
|
||||||
|
|||||||
@ -5,6 +5,7 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite --open",
|
"dev": "vite --open",
|
||||||
|
"clean": "node scripts/clean-dist.mjs",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview"
|
"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 {Object} options 额外选项
|
||||||
* @param {string|number} [options.cate] 文件分组,0 为未分类
|
* @param {string|number} [options.cate] 文件分组,0 为未分类
|
||||||
* @param {string|number} [options.tuid] 租户用户 yz_tenant_user.id;租户侧上传时传,平台管理员不传
|
* @param {string|number} [options.tuid] 租户用户 yz_tenant_user.id;租户侧上传时传,平台管理员不传
|
||||||
|
* @param {(e: { loaded: number; total?: number }) => void} [options.onUploadProgress] 上传进度(浏览器 XHR)
|
||||||
* @returns {Promise}
|
* @returns {Promise}
|
||||||
*/
|
*/
|
||||||
export function uploadFile(formData, options = {}) {
|
export function uploadFile(formData, options = {}) {
|
||||||
@ -115,14 +116,17 @@ export function uploadFile(formData, options = {}) {
|
|||||||
formData.append("tuid", String(options.tuid));
|
formData.append("tuid", String(options.tuid));
|
||||||
}
|
}
|
||||||
|
|
||||||
return request({
|
const config = {
|
||||||
url: "/platform/uploadfile",
|
url: "/platform/uploadfile",
|
||||||
method: "post",
|
method: "post",
|
||||||
data: formData,
|
data: formData,
|
||||||
headers: {
|
// 大安装包 / 极弱网:2 小时;勿设置 Content-Type(由浏览器自动带 boundary)
|
||||||
"Content-Type": "multipart/form-data"
|
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,
|
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
|
// 获取API基础URL;开发环境可在 .env.development 留空,配合 Vite 代理访问 /platform
|
||||||
const apiBaseURL = import.meta.env.VITE_API_BASE_URL ?? "";
|
const apiBaseURL = import.meta.env.VITE_API_BASE_URL ?? "";
|
||||||
|
|
||||||
// 创建axios实例
|
// 创建axios实例(普通接口 5min;大文件上传在 api/file.js 单独更长 timeout)
|
||||||
const service = axios.create({
|
const service = axios.create({
|
||||||
baseURL: apiBaseURL,
|
baseURL: apiBaseURL,
|
||||||
timeout: 10000,
|
timeout: 300000,
|
||||||
withCredentials: false // JWT 不需要 Cookie
|
withCredentials: false // JWT 不需要 Cookie
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -18,9 +18,12 @@ service.interceptors.request.use(
|
|||||||
config.headers['Authorization'] = `Bearer ${token}`;
|
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.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';
|
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/
|
// https://vite.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
|
// Windows 下若 dist 被占用,清空或覆盖会 EPERM;产物默认写到 output/(与 dist 分离)。部署时请同步指向 output。
|
||||||
|
build: {
|
||||||
|
outDir: "output",
|
||||||
|
emptyOutDir: true,
|
||||||
|
},
|
||||||
plugins: [
|
plugins: [
|
||||||
vue(),
|
vue(),
|
||||||
AutoImport({
|
AutoImport({
|
||||||
@ -23,10 +28,14 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
server: {
|
server: {
|
||||||
port: 5000,
|
port: 5000,
|
||||||
// 开发时前端在 5000,接口走相对路径 /platform/*,由这里转发到 Go(go/conf/app.conf 默认 httpport=8080)
|
// 开发时前端在 5000,接口走相对路径 /platform/*、/backend/*,转发到本地 Go(当前 httpport=8081)
|
||||||
proxy: {
|
proxy: {
|
||||||
"/platform": {
|
"/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,
|
changeOrigin: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user