This commit is contained in:
wushumin
2026-05-11 15:28:27 +08:00
commit 9aac78b8da
289 changed files with 67193 additions and 0 deletions

3
admin-web/src/App.vue Normal file
View File

@@ -0,0 +1,3 @@
<template>
<router-view />
</template>

2072
admin-web/src/api/admin.ts Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,63 @@
import axios from "axios";
import { clearAdminSession, getAdminToken } from "../utils/auth";
import { resolveApiBaseUrl } from "../utils/env";
import { goToAdminLogin } from "../utils/navigation";
interface ApiPayload {
code?: number;
message?: string;
data?: unknown;
}
function redirectToLoginOnUnauthorized() {
clearAdminSession();
goToAdminLogin();
}
const request = axios.create({
baseURL: resolveApiBaseUrl(),
timeout: 20000,
});
request.interceptors.request.use((config) => {
const token = getAdminToken();
if (token) {
config.headers = config.headers || {};
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
request.interceptors.response.use(
(response) => {
if (response.config.responseType === "blob" || response.config.responseType === "arraybuffer") {
return response.data as any;
}
const payload = response.data as ApiPayload;
if (payload?.code === 0) {
return payload as any;
}
if (payload?.code === 401) {
redirectToLoginOnUnauthorized();
}
const error = new Error(payload?.message || "请求失败") as Error & {
payload?: ApiPayload;
status?: number;
};
error.payload = payload;
error.status = response.status;
return Promise.reject(error);
},
(error) => {
const status = error?.response?.status;
if (status === 401) {
redirectToLoginOnUnauthorized();
}
return Promise.reject(error);
},
);
export default request;

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.5 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>

After

Width:  |  Height:  |  Size: 496 B

View File

@@ -0,0 +1,93 @@
<script setup lang="ts">
import { ref } from 'vue'
import viteLogo from '../assets/vite.svg'
import heroImg from '../assets/hero.png'
import vueLogo from '../assets/vue.svg'
const count = ref(0)
</script>
<template>
<section id="center">
<div class="hero">
<img :src="heroImg" class="base" width="170" height="179" alt="" />
<img :src="vueLogo" class="framework" alt="Vue logo" />
<img :src="viteLogo" class="vite" alt="Vite logo" />
</div>
<div>
<h1>Get started</h1>
<p>Edit <code>src/App.vue</code> and save to test <code>HMR</code></p>
</div>
<button class="counter" @click="count++">Count is {{ count }}</button>
</section>
<div class="ticks"></div>
<section id="next-steps">
<div id="docs">
<svg class="icon" role="presentation" aria-hidden="true">
<use href="/icons.svg#documentation-icon"></use>
</svg>
<h2>Documentation</h2>
<p>Your questions, answered</p>
<ul>
<li>
<a href="https://vite.dev/" target="_blank">
<img class="logo" :src="viteLogo" alt="" />
Explore Vite
</a>
</li>
<li>
<a href="https://vuejs.org/" target="_blank">
<img class="button-icon" :src="vueLogo" alt="" />
Learn more
</a>
</li>
</ul>
</div>
<div id="social">
<svg class="icon" role="presentation" aria-hidden="true">
<use href="/icons.svg#social-icon"></use>
</svg>
<h2>Connect with us</h2>
<p>Join the Vite community</p>
<ul>
<li>
<a href="https://github.com/vitejs/vite" target="_blank">
<svg class="button-icon" role="presentation" aria-hidden="true">
<use href="/icons.svg#github-icon"></use>
</svg>
GitHub
</a>
</li>
<li>
<a href="https://chat.vite.dev/" target="_blank">
<svg class="button-icon" role="presentation" aria-hidden="true">
<use href="/icons.svg#discord-icon"></use>
</svg>
Discord
</a>
</li>
<li>
<a href="https://x.com/vite_js" target="_blank">
<svg class="button-icon" role="presentation" aria-hidden="true">
<use href="/icons.svg#x-icon"></use>
</svg>
X.com
</a>
</li>
<li>
<a href="https://bsky.app/profile/vite.dev" target="_blank">
<svg class="button-icon" role="presentation" aria-hidden="true">
<use href="/icons.svg#bluesky-icon"></use>
</svg>
Bluesky
</a>
</li>
</ul>
</div>
</section>
<div class="ticks"></div>
<section id="spacer"></section>
</template>

View File

@@ -0,0 +1,44 @@
<script setup lang="ts">
import { computed } from "vue";
const props = defineProps<{
status: string;
}>();
type StatusTone = "success" | "warning" | "danger" | "progress" | "neutral";
const statusRules: Array<{ tone: StatusTone; keywords: string[] }> = [
{
tone: "danger",
keywords: ["失败", "作废", "失效", "异常", "停用", "禁用", "未启用", "关闭", "取消"],
},
{
tone: "success",
keywords: ["报告已出具", "已出报告", "已发布", "已完成", "已解决", "已启用", "账号正常", "发送成功", "成功"],
},
{
tone: "warning",
keywords: ["待补", "待寄", "待处理", "待发布", "待提交", "待用户反馈", "草稿"],
},
{
tone: "progress",
keywords: ["处理中", "处理", "鉴定", "收货", "已提交", "已更新", "进行中"],
},
];
const statusTone = computed<StatusTone>(() => {
const text = props.status.trim();
if (!text) {
return "neutral";
}
const matchedRule = statusRules.find((rule) => rule.keywords.some((keyword) => text.includes(keyword)));
return matchedRule?.tone ?? "neutral";
});
const statusClass = computed(() => ["status-tag", `status-tag--${statusTone.value}`]);
</script>
<template>
<span :class="statusClass">{{ status }}</span>
</template>

View File

@@ -0,0 +1,93 @@
<script setup lang="ts">
import { computed } from "vue";
import { useRoute, useRouter } from "vue-router";
import { House, Tickets, CollectionTag, DocumentChecked, DataAnalysis, Bell, ChatLineRound, User, Lock, Setting, OfficeBuilding, Connection, Box } from "@element-plus/icons-vue";
import { ElMessage } from "element-plus";
import { adminApi } from "../api/admin";
import { clearAdminSession, getAdminInfo, hasPermission } from "../utils/auth";
import { goToAdminLogin } from "../utils/navigation";
const route = useRoute();
const router = useRouter();
const title = computed(() => (route.meta.title as string) || "安心验管理后台");
const desc = computed(() => (route.meta.desc as string) || "管理后台");
const active = computed(() => (route.meta.menuIndex as string) || (route.name as string));
const adminInfo = computed(() => getAdminInfo());
const menus = [
{ index: "dashboard", label: "工作台", icon: House, permission: "dashboard.view" },
{ index: "orders", label: "订单中心", icon: Tickets, permission: "orders.manage" },
{ index: "appraisal-tasks", label: "鉴定作业台", icon: DataAnalysis, permission: "appraisal_tasks.manage" },
{ index: "catalog", label: "商品资料中心", icon: CollectionTag, permission: "catalog.manage" },
{ index: "reports", label: "报告中心", icon: DocumentChecked, permission: "reports.manage" },
{ index: "messages", label: "消息中心", icon: Bell, permission: "messages.manage" },
{ index: "tickets", label: "客服与售后", icon: ChatLineRound, permission: "tickets.manage" },
{ index: "users", label: "用户管理", icon: User, permission: "users.manage" },
{ index: "customers", label: "客户管理", icon: Connection, permission: "customers.manage" },
{ index: "warehouses", label: "仓库中心", icon: OfficeBuilding, permission: "warehouses.manage" },
{ index: "materials", label: "物料管理", icon: Box, permission: "materials.manage" },
{ index: "access", label: "权限中心", icon: Lock, permission: "access.manage" },
{ index: "content", label: "内容中心", icon: DocumentChecked, permission: "system.manage" },
{ index: "system-config", label: "系统配置", icon: Setting, permission: "system.manage" },
];
const visibleMenus = computed(() => menus.filter((item) => hasPermission(item.permission)));
function handleSelect(index: string) {
router.push({ name: index });
}
async function logout() {
try {
await adminApi.logout();
} catch (error) {
console.error(error);
} finally {
clearAdminSession();
ElMessage.success("已退出登录");
goToAdminLogin();
}
}
</script>
<template>
<el-container class="admin-layout">
<el-aside width="250px" class="admin-aside">
<div class="admin-brand">
<div class="admin-brand__name">安心验</div>
<div class="admin-brand__desc">独立第三方鉴定服务管理后台</div>
</div>
<el-menu :default-active="active" @select="handleSelect">
<el-menu-item v-for="item in visibleMenus" :key="item.index" :index="item.index">
<el-icon><component :is="item.icon" /></el-icon>
<span>{{ item.label }}</span>
</el-menu-item>
</el-menu>
</el-aside>
<el-container>
<el-main class="admin-main">
<div class="admin-topbar">
<div>
<div class="admin-topbar__title">{{ title }}</div>
<div class="admin-topbar__desc">{{ desc }}</div>
</div>
<div class="admin-topbar__meta">
<span v-if="adminInfo" class="admin-chip">{{ adminInfo.name }}</span>
<span v-if="adminInfo" class="admin-chip">{{ adminInfo.role_names.join(" / ") || "未分配角色" }}</span>
<span class="admin-chip">MVP 阶段</span>
<span class="admin-chip">订单履约系统</span>
<span class="admin-chip" style="cursor: pointer" @click="logout">退出登录</span>
</div>
</div>
<div class="admin-content">
<router-view />
</div>
</el-main>
</el-container>
</el-container>
</template>

15
admin-web/src/main.ts Normal file
View File

@@ -0,0 +1,15 @@
import { createApp } from "vue";
import { createPinia } from "pinia";
import ElementPlus from "element-plus";
import "element-plus/dist/index.css";
import "./style.css";
import App from "./App.vue";
import router from "./router";
import { setAppRouter } from "./utils/navigation";
const app = createApp(App);
app.use(createPinia());
app.use(router);
app.use(ElementPlus);
setAppRouter(router);
app.mount("#app");

View File

@@ -0,0 +1,274 @@
<script setup lang="ts">
import { onMounted, reactive, ref } from "vue";
import { ElMessage } from "element-plus";
import {
adminApi,
type AdminAccessOverviewCard,
type AdminManagerItem,
type AdminManagerPayload,
type AdminPermissionItem,
type AdminRoleItem,
type AdminRolePayload,
} from "../../api/admin";
import OrderStatusTag from "../../components/OrderStatusTag.vue";
const loading = ref(false);
const cards = ref<AdminAccessOverviewCard[]>([]);
const admins = ref<AdminManagerItem[]>([]);
const roles = ref<AdminRoleItem[]>([]);
const permissions = ref<AdminPermissionItem[]>([]);
const adminDialogVisible = ref(false);
const roleDialogVisible = ref(false);
const adminSubmitting = ref(false);
const roleSubmitting = ref(false);
const adminForm = reactive<AdminManagerPayload>({
name: "",
mobile: "",
email: "",
password: "",
status: "enabled",
role_ids: [],
});
const roleForm = reactive<AdminRolePayload>({
name: "",
code: "",
status: "enabled",
permission_ids: [],
});
async function fetchAll() {
loading.value = true;
try {
const [overviewRes, adminsRes, rolesRes, permissionsRes] = await Promise.all([
adminApi.getAccessOverview(),
adminApi.getAdmins(),
adminApi.getRoles(),
adminApi.getPermissions(),
]);
cards.value = overviewRes.data.cards;
admins.value = adminsRes.data.list;
roles.value = rolesRes.data.list;
permissions.value = permissionsRes.data.list;
} catch (error) {
console.error(error);
ElMessage.error("权限中心数据加载失败");
} finally {
loading.value = false;
}
}
function openAdminDialog(row?: AdminManagerItem) {
if (row) {
adminForm.id = row.id;
adminForm.name = row.name;
adminForm.mobile = row.mobile;
adminForm.email = row.email;
adminForm.password = "";
adminForm.status = row.status;
adminForm.role_ids = [...row.role_ids];
} else {
adminForm.id = undefined;
adminForm.name = "";
adminForm.mobile = "";
adminForm.email = "";
adminForm.password = "";
adminForm.status = "enabled";
adminForm.role_ids = roles.value.length ? [roles.value[0].id] : [];
}
adminDialogVisible.value = true;
}
async function submitAdmin() {
adminSubmitting.value = true;
try {
await adminApi.saveAdmin({ ...adminForm, role_ids: [...adminForm.role_ids] });
ElMessage.success(adminForm.id ? "管理员更新成功" : "管理员创建成功");
adminDialogVisible.value = false;
await fetchAll();
} catch (error) {
console.error(error);
ElMessage.error("管理员保存失败");
} finally {
adminSubmitting.value = false;
}
}
function openRoleDialog(row?: AdminRoleItem) {
if (row) {
roleForm.id = row.id;
roleForm.name = row.name;
roleForm.code = row.code;
roleForm.status = row.status;
roleForm.permission_ids = [...row.permission_ids];
} else {
roleForm.id = undefined;
roleForm.name = "";
roleForm.code = "";
roleForm.status = "enabled";
roleForm.permission_ids = permissions.value.map((item) => item.id);
}
roleDialogVisible.value = true;
}
async function submitRole() {
roleSubmitting.value = true;
try {
await adminApi.saveRole({ ...roleForm, permission_ids: [...roleForm.permission_ids] });
ElMessage.success(roleForm.id ? "角色更新成功" : "角色创建成功");
roleDialogVisible.value = false;
await fetchAll();
} catch (error) {
console.error(error);
ElMessage.error("角色保存失败");
} finally {
roleSubmitting.value = false;
}
}
onMounted(fetchAll);
</script>
<template>
<div v-loading="loading">
<div class="metric-grid" style="margin-bottom: 18px">
<div v-for="item in cards" :key="item.title" class="metric-card">
<div class="metric-card__label">{{ item.title }}</div>
<div class="metric-card__value">{{ item.value }}</div>
<div class="metric-card__desc">{{ item.desc }}</div>
</div>
</div>
<el-card class="panel-card" shadow="never">
<el-tabs>
<el-tab-pane label="管理员账号">
<div class="filters-row" style="margin-bottom: 16px">
<el-button type="primary" @click="openAdminDialog()">新增管理员</el-button>
</div>
<el-table :data="admins" stripe>
<el-table-column prop="name" label="姓名" min-width="140" />
<el-table-column prop="mobile" label="手机号" min-width="140" />
<el-table-column prop="email" label="邮箱" min-width="200" />
<el-table-column label="状态" min-width="100">
<template #default="{ row }">
<OrderStatusTag :status="row.status_text" />
</template>
</el-table-column>
<el-table-column label="角色" min-width="220">
<template #default="{ row }">
{{ row.role_names.join(" / ") || "未分配角色" }}
</template>
</el-table-column>
<el-table-column prop="last_login_at" label="最近登录" min-width="170" />
<el-table-column label="操作" fixed="right" width="100">
<template #default="{ row }">
<el-button link type="primary" @click="openAdminDialog(row)">编辑</el-button>
</template>
</el-table-column>
</el-table>
</el-tab-pane>
<el-tab-pane label="角色配置">
<div class="filters-row" style="margin-bottom: 16px">
<el-button type="primary" @click="openRoleDialog()">新增角色</el-button>
</div>
<el-table :data="roles" stripe>
<el-table-column prop="name" label="角色名称" min-width="140" />
<el-table-column prop="code" label="角色编码" min-width="160" />
<el-table-column label="状态" min-width="100">
<template #default="{ row }">
<OrderStatusTag :status="row.status_text" />
</template>
</el-table-column>
<el-table-column prop="admin_count" label="管理员数" min-width="100" />
<el-table-column label="权限摘要" min-width="280">
<template #default="{ row }">
{{ row.permission_names.join(" / ") || "未分配权限" }}
</template>
</el-table-column>
<el-table-column label="操作" fixed="right" width="100">
<template #default="{ row }">
<el-button link type="primary" @click="openRoleDialog(row)">编辑</el-button>
</template>
</el-table-column>
</el-table>
</el-tab-pane>
<el-tab-pane label="权限点">
<el-table :data="permissions" stripe>
<el-table-column prop="name" label="权限名称" min-width="180" />
<el-table-column prop="code" label="权限编码" min-width="220" />
<el-table-column prop="module_text" label="所属模块" min-width="140" />
<el-table-column prop="action" label="动作" min-width="120" />
</el-table>
</el-tab-pane>
</el-tabs>
</el-card>
<el-dialog v-model="adminDialogVisible" :title="adminForm.id ? '编辑管理员' : '新增管理员'" width="560px">
<el-form label-position="top">
<el-form-item label="姓名">
<el-input v-model="adminForm.name" placeholder="请输入管理员姓名" />
</el-form-item>
<el-form-item label="手机号">
<el-input v-model="adminForm.mobile" placeholder="请输入管理员手机号" />
</el-form-item>
<el-form-item label="邮箱">
<el-input v-model="adminForm.email" placeholder="请输入管理员邮箱" />
</el-form-item>
<el-form-item :label="adminForm.id ? '登录密码(留空则不修改)' : '登录密码'">
<el-input v-model="adminForm.password" type="password" show-password placeholder="请输入管理员登录密码" />
</el-form-item>
<el-form-item label="账号状态">
<el-radio-group v-model="adminForm.status">
<el-radio value="enabled">启用</el-radio>
<el-radio value="disabled">停用</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="角色分配">
<el-select v-model="adminForm.role_ids" multiple style="width: 100%">
<el-option v-for="item in roles" :key="item.id" :label="item.name" :value="item.id" />
</el-select>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="adminDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="adminSubmitting" @click="submitAdmin">保存</el-button>
</template>
</el-dialog>
<el-dialog v-model="roleDialogVisible" :title="roleForm.id ? '编辑角色' : '新增角色'" width="640px">
<el-form label-position="top">
<el-form-item label="角色名称">
<el-input v-model="roleForm.name" placeholder="请输入角色名称" />
</el-form-item>
<el-form-item label="角色编码">
<el-input v-model="roleForm.code" placeholder="请输入角色编码,如 operations_manager" />
</el-form-item>
<el-form-item label="角色状态">
<el-radio-group v-model="roleForm.status">
<el-radio value="enabled">启用</el-radio>
<el-radio value="disabled">停用</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="权限分配">
<el-select v-model="roleForm.permission_ids" multiple style="width: 100%">
<el-option
v-for="item in permissions"
:key="item.id"
:label="`${item.module_text} / ${item.name}`"
:value="item.id"
/>
</el-select>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="roleDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="roleSubmitting" @click="submitRole">保存</el-button>
</template>
</el-dialog>
</div>
</template>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,190 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from "vue";
import { ElMessage, ElMessageBox } from "element-plus";
import { adminApi, type AdminHelpArticleItem } from "../../api/admin";
import { articleCategoryOptions, parseLines, resetArticleForm, type ArticleFormState } from "./shared";
const loading = ref(false);
const articleSaving = ref(false);
const articleDialogVisible = ref(false);
const articles = ref<AdminHelpArticleItem[]>([]);
const articleForm = reactive<ArticleFormState>({
category: "service",
title: "",
summary: "",
keywordsText: "",
contentBlocksText: "",
is_recommended: false,
is_enabled: true,
sort_order: 0,
});
const articleStats = computed(() => ({
total: articles.value.length,
enabled: articles.value.filter((item) => item.is_enabled).length,
recommended: articles.value.filter((item) => item.is_recommended).length,
}));
async function fetchArticles() {
loading.value = true;
try {
const response = await adminApi.getHelpArticles();
articles.value = response.data.list;
} catch (error) {
console.error(error);
ElMessage.error("帮助文章加载失败");
} finally {
loading.value = false;
}
}
function openCreateArticle() {
resetArticleForm(articleForm);
articleDialogVisible.value = true;
}
function openEditArticle(row: AdminHelpArticleItem) {
resetArticleForm(articleForm, row);
articleDialogVisible.value = true;
}
async function submitArticle() {
articleSaving.value = true;
try {
await adminApi.saveHelpArticle({
id: articleForm.id,
category: articleForm.category,
title: articleForm.title.trim(),
summary: articleForm.summary.trim(),
keywords: parseLines(articleForm.keywordsText),
content_blocks: parseLines(articleForm.contentBlocksText),
is_recommended: articleForm.is_recommended,
is_enabled: articleForm.is_enabled,
sort_order: articleForm.sort_order,
});
ElMessage.success(articleForm.id ? "帮助文章已更新" : "帮助文章已创建");
articleDialogVisible.value = false;
await fetchArticles();
} catch (error) {
console.error(error);
ElMessage.error("帮助文章保存失败");
} finally {
articleSaving.value = false;
}
}
async function deleteArticle(row: AdminHelpArticleItem) {
try {
await ElMessageBox.confirm(`确定删除文章「${row.title}」吗?`, "删除帮助文章", {
type: "warning",
confirmButtonText: "确认删除",
cancelButtonText: "取消",
});
} catch {
return;
}
try {
await adminApi.deleteHelpArticle(row.id);
ElMessage.success("帮助文章已删除");
await fetchArticles();
} catch (error) {
console.error(error);
ElMessage.error("帮助文章删除失败");
}
}
onMounted(fetchArticles);
</script>
<template>
<el-card class="panel-card" shadow="never" v-loading="loading">
<div class="filters-row" style="justify-content: space-between;">
<div>
<div style="font-size: 16px; font-weight: 700;">帮助中心文章</div>
<div style="color: var(--admin-text-subtle); margin-top: 6px;">
当前共 {{ articleStats.total }} 篇文章其中 {{ articleStats.enabled }} 篇启用{{ articleStats.recommended }} 篇推荐
</div>
</div>
<el-button type="primary" @click="openCreateArticle">新增文章</el-button>
</div>
<el-table :data="articles" stripe>
<el-table-column prop="title" label="标题" min-width="260" />
<el-table-column prop="category_text" label="分类" min-width="120" />
<el-table-column label="推荐" width="90">
<template #default="{ row }">
<el-tag :type="row.is_recommended ? 'warning' : 'info'">{{ row.is_recommended ? "是" : "否" }}</el-tag>
</template>
</el-table-column>
<el-table-column label="启用" width="90">
<template #default="{ row }">
<el-tag :type="row.is_enabled ? 'success' : 'info'">{{ row.is_enabled ? "启用" : "停用" }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="sort_order" label="排序" width="90" />
<el-table-column prop="updated_at" label="更新时间" min-width="170" />
<el-table-column label="操作" width="150" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click="openEditArticle(row)">编辑</el-button>
<el-button link type="danger" @click="deleteArticle(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
<el-dialog v-model="articleDialogVisible" :title="articleForm.id ? '编辑帮助文章' : '新增帮助文章'" width="760px">
<el-form label-position="top">
<el-row :gutter="16">
<el-col :span="8">
<el-form-item label="分类">
<el-select v-model="articleForm.category" style="width: 100%">
<el-option v-for="item in articleCategoryOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="排序">
<el-input-number v-model="articleForm.sort_order" :min="0" :step="10" style="width: 100%" />
</el-form-item>
</el-col>
<el-col :span="4">
<el-form-item label="推荐">
<el-switch v-model="articleForm.is_recommended" inline-prompt active-text="是" inactive-text="否" />
</el-form-item>
</el-col>
<el-col :span="4">
<el-form-item label="启用">
<el-switch v-model="articleForm.is_enabled" inline-prompt active-text="启用" inactive-text="停用" />
</el-form-item>
</el-col>
<el-col :span="24">
<el-form-item label="标题">
<el-input v-model="articleForm.title" />
</el-form-item>
</el-col>
<el-col :span="24">
<el-form-item label="摘要">
<el-input v-model="articleForm.summary" type="textarea" :rows="2" />
</el-form-item>
</el-col>
<el-col :span="24">
<el-form-item label="关键词">
<el-input v-model="articleForm.keywordsText" type="textarea" :rows="3" placeholder="每行一个关键词" />
</el-form-item>
</el-col>
<el-col :span="24">
<el-form-item label="正文内容">
<el-input v-model="articleForm.contentBlocksText" type="textarea" :rows="8" placeholder="每行一段正文内容" />
</el-form-item>
</el-col>
</el-row>
</el-form>
<template #footer>
<el-button @click="articleDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="articleSaving" @click="submitArticle">保存</el-button>
</template>
</el-dialog>
</template>

View File

@@ -0,0 +1,413 @@
<script setup lang="ts">
import { onMounted, ref } from "vue";
import { ElMessage, type UploadRequestOptions } from "element-plus";
import { adminApi, type AdminContentHomeConfig } from "../../api/admin";
import { createHomeConfig, normalizeHomeConfig, quickCodeOptions, serviceProviderOptions, type HomeSectionKey } from "./shared";
const loading = ref(false);
const saving = ref(false);
const uploadingKey = ref("");
const homeForm = ref<AdminContentHomeConfig>(createHomeConfig());
type PageVisualField = keyof AdminContentHomeConfig["page_visuals"];
async function fetchHome() {
loading.value = true;
try {
const homeResponse = await adminApi.getContentHome();
homeForm.value = normalizeHomeConfig(homeResponse.data.home_config);
} catch (error) {
console.error(error);
ElMessage.error("内容配置加载失败");
} finally {
loading.value = false;
}
}
function addSectionItem(section: HomeSectionKey) {
if (section === "service_entries") {
homeForm.value.service_entries.push({
service_provider: "anxinyan",
title: "",
tag: "",
description: "",
meta: "",
});
return;
}
if (section === "quick_entries") {
homeForm.value.quick_entries.push({
code: "start",
title: "",
desc: "",
});
return;
}
if (section === "trust_metrics") {
homeForm.value.trust_metrics.push({
value: "",
label: "",
});
return;
}
if (section === "trust_points") {
homeForm.value.trust_points.push({
title: "",
desc: "",
});
return;
}
homeForm.value.faqs.push("");
}
function removeSectionItem(section: HomeSectionKey, index: number) {
if (section === "service_entries") {
homeForm.value.service_entries.splice(index, 1);
return;
}
if (section === "quick_entries") {
homeForm.value.quick_entries.splice(index, 1);
return;
}
if (section === "trust_metrics") {
homeForm.value.trust_metrics.splice(index, 1);
return;
}
if (section === "trust_points") {
homeForm.value.trust_points.splice(index, 1);
return;
}
homeForm.value.faqs.splice(index, 1);
}
function beforeImageUpload(file: File) {
if (!file.type.startsWith("image/")) {
ElMessage.error("仅支持上传图片文件");
return false;
}
if (file.size > 5 * 1024 * 1024) {
ElMessage.error("图片大小不能超过 5MB");
return false;
}
return true;
}
async function uploadHomeImage(options: UploadRequestOptions, applyUrl: (url: string) => void, key: string) {
uploadingKey.value = key;
try {
const response = await adminApi.uploadContentImage(options.file as File);
applyUrl(response.data.file_url);
ElMessage.success("图片已上传");
} catch (error) {
console.error(error);
ElMessage.error("图片上传失败");
} finally {
uploadingKey.value = "";
}
}
function uploadBannerImage(options: UploadRequestOptions) {
return uploadHomeImage(options, (url) => {
homeForm.value.banners[0].background_image_url = url;
}, "banner");
}
function uploadPageVisualImage(options: UploadRequestOptions, field: PageVisualField, key: string) {
return uploadHomeImage(options, (url) => {
homeForm.value.page_visuals[field] = url;
}, key);
}
function uploadOrderBackgroundImage(options: UploadRequestOptions) {
return uploadPageVisualImage(options, "order_background_image_url", "page-order");
}
function uploadReportBackgroundImage(options: UploadRequestOptions) {
return uploadPageVisualImage(options, "report_background_image_url", "page-report");
}
async function saveHome() {
saving.value = true;
try {
const { category_visuals: _categoryVisuals, ...homeConfigPayload } = homeForm.value;
const response = await adminApi.saveContentHome(homeConfigPayload);
homeForm.value = normalizeHomeConfig(response.data.home_config);
ElMessage.success("内容配置已保存");
} catch (error) {
console.error(error);
ElMessage.error("内容配置保存失败");
} finally {
saving.value = false;
}
}
onMounted(fetchHome);
</script>
<template>
<el-card class="panel-card" shadow="never" v-loading="loading">
<div class="filters-row" style="justify-content: space-between;">
<div>
<div style="font-size: 16px; font-weight: 700;">首页与主页面内容</div>
<div style="color: var(--admin-text-subtle); margin-top: 6px;">
维护首页首屏文案订单/报告主页面背景服务入口信任指标与 FAQ 摘要
</div>
</div>
<el-button type="primary" :loading="saving" @click="saveHome">保存内容配置</el-button>
</div>
<el-form label-position="top">
<el-divider content-position="left">首屏 Banner</el-divider>
<el-row :gutter="16">
<el-col :span="8">
<el-form-item label="角标标题">
<el-input v-model="homeForm.banners[0].title" placeholder="例如:安心验" />
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="主标题">
<el-input v-model="homeForm.banners[0].subtitle" placeholder="例如:独立第三方鉴定服务平台" />
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="说明文案">
<el-input v-model="homeForm.banners[0].description" placeholder="请输入首页首屏说明文案" />
</el-form-item>
</el-col>
<el-col :span="16">
<el-form-item label="顶部背景图 URL">
<el-input v-model="homeForm.banners[0].background_image_url" placeholder="可粘贴图片 URL留空则使用前端默认图" />
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="上传顶部背景图">
<div class="content-image-field">
<img
v-if="homeForm.banners[0].background_image_url"
:src="homeForm.banners[0].background_image_url"
alt="顶部背景图预览"
class="content-image-field__preview content-image-field__preview--wide"
/>
<div v-else class="content-image-field__placeholder">未配置图片</div>
<el-upload
:show-file-list="false"
accept="image/*"
:before-upload="beforeImageUpload"
:http-request="uploadBannerImage"
>
<el-button :loading="uploadingKey === 'banner'">上传图片</el-button>
</el-upload>
</div>
</el-form-item>
</el-col>
</el-row>
<el-divider content-position="left">主页面背景图</el-divider>
<div class="filters-row" style="justify-content: space-between; margin-bottom: 12px;">
<div style="color: var(--admin-text-subtle);">
配置用户端订单中心报告中心顶部背景图留空时用户端使用当前默认设计图
</div>
</div>
<el-row :gutter="16">
<el-col :span="12">
<div class="content-image-config">
<el-form-item label="订单页顶部背景图 URL">
<el-input v-model="homeForm.page_visuals.order_background_image_url" placeholder="可粘贴图片 URL留空则使用前端默认图" />
</el-form-item>
<el-form-item label="上传订单页顶部背景图">
<div class="content-image-field">
<img
v-if="homeForm.page_visuals.order_background_image_url"
:src="homeForm.page_visuals.order_background_image_url"
alt="订单页顶部背景图预览"
class="content-image-field__preview content-image-field__preview--wide"
/>
<div v-else class="content-image-field__placeholder">未配置图片</div>
<el-upload
:show-file-list="false"
accept="image/*"
:before-upload="beforeImageUpload"
:http-request="uploadOrderBackgroundImage"
>
<el-button :loading="uploadingKey === 'page-order'">上传图片</el-button>
</el-upload>
</div>
</el-form-item>
</div>
</el-col>
<el-col :span="12">
<div class="content-image-config">
<el-form-item label="报告页顶部背景图 URL">
<el-input v-model="homeForm.page_visuals.report_background_image_url" placeholder="可粘贴图片 URL留空则使用前端默认图" />
</el-form-item>
<el-form-item label="上传报告页顶部背景图">
<div class="content-image-field">
<img
v-if="homeForm.page_visuals.report_background_image_url"
:src="homeForm.page_visuals.report_background_image_url"
alt="报告页顶部背景图预览"
class="content-image-field__preview content-image-field__preview--wide"
/>
<div v-else class="content-image-field__placeholder">未配置图片</div>
<el-upload
:show-file-list="false"
accept="image/*"
:before-upload="beforeImageUpload"
:http-request="uploadReportBackgroundImage"
>
<el-button :loading="uploadingKey === 'page-report'">上传图片</el-button>
</el-upload>
</div>
</el-form-item>
</div>
</el-col>
</el-row>
<el-divider content-position="left">服务入口</el-divider>
<div v-for="(item, index) in homeForm.service_entries" :key="`service-${index}`" class="content-block">
<div class="content-block__header">
<div class="content-block__title">服务卡片 {{ index + 1 }}</div>
<el-button link type="danger" @click="removeSectionItem('service_entries', index)">删除</el-button>
</div>
<el-row :gutter="16">
<el-col :span="6">
<el-form-item label="服务类型">
<el-select v-model="item.service_provider" style="width: 100%">
<el-option v-for="option in serviceProviderOptions" :key="option.value" :label="option.label" :value="option.value" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="6"><el-form-item label="标题"><el-input v-model="item.title" /></el-form-item></el-col>
<el-col :span="6"><el-form-item label="标签"><el-input v-model="item.tag" /></el-form-item></el-col>
<el-col :span="6"><el-form-item label="补充信息"><el-input v-model="item.meta" /></el-form-item></el-col>
<el-col :span="24"><el-form-item label="描述"><el-input v-model="item.description" type="textarea" :rows="2" /></el-form-item></el-col>
</el-row>
</div>
<el-button plain @click="addSectionItem('service_entries')">新增服务卡片</el-button>
<el-divider content-position="left">快捷入口</el-divider>
<div v-for="(item, index) in homeForm.quick_entries" :key="`quick-${index}`" class="content-block">
<div class="content-block__header">
<div class="content-block__title">快捷入口 {{ index + 1 }}</div>
<el-button link type="danger" @click="removeSectionItem('quick_entries', index)">删除</el-button>
</div>
<el-row :gutter="16">
<el-col :span="6">
<el-form-item label="入口编码">
<el-select v-model="item.code" style="width: 100%">
<el-option v-for="option in quickCodeOptions" :key="option.value" :label="option.label" :value="option.value" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="8"><el-form-item label="标题"><el-input v-model="item.title" /></el-form-item></el-col>
<el-col :span="10"><el-form-item label="说明"><el-input v-model="item.desc" /></el-form-item></el-col>
</el-row>
</div>
<el-button plain @click="addSectionItem('quick_entries')">新增快捷入口</el-button>
<el-divider content-position="left">信任指标</el-divider>
<div v-for="(item, index) in homeForm.trust_metrics" :key="`metric-${index}`" class="content-block">
<div class="content-block__header">
<div class="content-block__title">指标 {{ index + 1 }}</div>
<el-button link type="danger" @click="removeSectionItem('trust_metrics', index)">删除</el-button>
</div>
<el-row :gutter="16">
<el-col :span="8"><el-form-item label="数值"><el-input v-model="item.value" /></el-form-item></el-col>
<el-col :span="16"><el-form-item label="标签"><el-input v-model="item.label" /></el-form-item></el-col>
</el-row>
</div>
<el-button plain @click="addSectionItem('trust_metrics')">新增信任指标</el-button>
<el-divider content-position="left">信任说明</el-divider>
<div v-for="(item, index) in homeForm.trust_points" :key="`trust-${index}`" class="content-block">
<div class="content-block__header">
<div class="content-block__title">说明项 {{ index + 1 }}</div>
<el-button link type="danger" @click="removeSectionItem('trust_points', index)">删除</el-button>
</div>
<el-row :gutter="16">
<el-col :span="8"><el-form-item label="标题"><el-input v-model="item.title" /></el-form-item></el-col>
<el-col :span="16"><el-form-item label="说明"><el-input v-model="item.desc" /></el-form-item></el-col>
</el-row>
</div>
<el-button plain @click="addSectionItem('trust_points')">新增信任说明</el-button>
<el-divider content-position="left">首页常见问题</el-divider>
<div v-for="index in homeForm.faqs.length" :key="`faq-${index - 1}`" class="content-block">
<div class="content-block__header">
<div class="content-block__title">问题 {{ index }}</div>
<el-button link type="danger" @click="removeSectionItem('faqs', index - 1)">删除</el-button>
</div>
<el-form-item label="问题标题">
<el-input v-model="homeForm.faqs[index - 1]" />
</el-form-item>
</div>
<el-button plain @click="addSectionItem('faqs')">新增问题</el-button>
</el-form>
</el-card>
</template>
<style scoped>
.content-block {
margin-bottom: 18px;
padding: 16px 18px;
border: 1px solid var(--admin-border);
border-radius: 14px;
background: linear-gradient(180deg, rgba(255, 251, 244, 0.7) 0%, rgba(255, 255, 255, 0.96) 100%);
}
.content-block__header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.content-block__title {
font-size: 14px;
font-weight: 700;
color: var(--admin-text);
}
.content-image-field {
display: flex;
gap: 12px;
align-items: center;
min-width: 0;
}
.content-image-config {
margin-bottom: 18px;
padding: 16px 18px;
border: 1px solid var(--admin-border);
border-radius: 14px;
background: linear-gradient(180deg, rgba(255, 251, 244, 0.7) 0%, rgba(255, 255, 255, 0.96) 100%);
}
.content-image-field__preview,
.content-image-field__placeholder {
width: 72px;
height: 56px;
border: 1px solid var(--admin-border);
border-radius: 10px;
background: #f6f7f9;
}
.content-image-field__preview {
object-fit: contain;
}
.content-image-field__preview--wide {
width: 112px;
}
.content-image-field__placeholder {
display: flex;
align-items: center;
justify-content: center;
color: var(--admin-text-subtle);
font-size: 12px;
}
</style>

View File

@@ -0,0 +1,83 @@
<script setup lang="ts">
import { computed } from "vue";
import { useRoute, useRouter } from "vue-router";
import { contentTabs } from "./shared";
const route = useRoute();
const router = useRouter();
const activeTab = computed(() => (route.meta.contentTab as string) || "home");
function switchTab(routeName: string) {
router.push({ name: routeName });
}
</script>
<template>
<div>
<el-card class="panel-card" shadow="never">
<div class="filters-row" style="justify-content: space-between;">
<div>
<div style="font-size: 18px; font-weight: 700;">内容中心</div>
<div style="color: var(--admin-text-subtle); margin-top: 6px;">
按模块拆分首页内容协议站内文案和帮助文章避免单页堆叠导致维护效率下降
</div>
</div>
</div>
</el-card>
<el-card class="panel-card" shadow="never">
<div class="content-tabs">
<button
v-for="tab in contentTabs"
:key="tab.key"
type="button"
:class="['content-tabs__item', activeTab === tab.key ? 'content-tabs__item--active' : '']"
@click="switchTab(tab.routeName)"
>
<div class="content-tabs__label">{{ tab.label }}</div>
<div class="content-tabs__desc">{{ tab.desc }}</div>
</button>
</div>
</el-card>
<router-view />
</div>
</template>
<style scoped>
.content-tabs {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 16px;
}
.content-tabs__item {
border: 1px solid var(--admin-border);
border-radius: 16px;
background: linear-gradient(180deg, rgba(255, 251, 244, 0.78) 0%, rgba(255, 255, 255, 0.96) 100%);
padding: 16px 18px;
text-align: left;
cursor: pointer;
transition: all 0.2s ease;
}
.content-tabs__item--active {
border-color: rgba(195, 149, 62, 0.42);
box-shadow: 0 10px 24px rgba(193, 140, 29, 0.12);
background: linear-gradient(180deg, rgba(255, 249, 237, 0.96) 0%, rgba(255, 255, 255, 0.98) 100%);
}
.content-tabs__label {
color: var(--admin-text);
font-size: 15px;
font-weight: 700;
}
.content-tabs__desc {
margin-top: 8px;
color: var(--admin-text-subtle);
font-size: 12px;
line-height: 1.6;
}
</style>

View File

@@ -0,0 +1,216 @@
<script setup lang="ts">
import { onMounted, ref } from "vue";
import { ElMessage } from "element-plus";
import { adminApi, type AdminContentMetaConfig } from "../../api/admin";
import { normalizeMetaConfig, type MetaSectionKey } from "./shared";
const loading = ref(false);
const saving = ref(false);
const metaForm = ref<AdminContentMetaConfig>({
help_categories: [],
report_risk_defaults: [],
ticket_types: [],
ticket_statuses: [],
message_events: [],
message_page_copy: {
title: "",
desc: "",
},
});
async function fetchMeta() {
loading.value = true;
try {
const response = await adminApi.getContentMeta();
metaForm.value = normalizeMetaConfig(response.data.meta_config);
} catch (error) {
console.error(error);
ElMessage.error("分类与文案加载失败");
} finally {
loading.value = false;
}
}
function addMetaItem(section: MetaSectionKey) {
if (section === "help_categories") {
metaForm.value.help_categories.push({
code: "",
title: "",
desc: "",
});
return;
}
if (section === "ticket_types") {
metaForm.value.ticket_types.push({
code: "",
title: "",
hint: "",
quick_desc: "",
});
return;
}
if (section === "message_events") {
metaForm.value.message_events.push({
event_code: "",
title: "",
desc: "",
});
return;
}
if (section === "ticket_statuses") {
metaForm.value.ticket_statuses.push({
code: "",
title: "",
desc: "",
});
return;
}
metaForm.value.report_risk_defaults.push({
report_type: "appraisal",
title: "",
text: "",
});
}
function removeMetaItem(section: MetaSectionKey, index: number) {
metaForm.value[section].splice(index, 1);
}
async function saveMeta() {
saving.value = true;
try {
const response = await adminApi.saveContentMeta(metaForm.value);
metaForm.value = normalizeMetaConfig(response.data.meta_config);
ElMessage.success("分类与文案已保存");
} catch (error) {
console.error(error);
ElMessage.error("分类与文案保存失败");
} finally {
saving.value = false;
}
}
onMounted(fetchMeta);
</script>
<template>
<el-card class="panel-card" shadow="never" v-loading="loading">
<div class="filters-row" style="justify-content: space-between;">
<div>
<div style="font-size: 16px; font-weight: 700;">分类与文案</div>
<div style="color: var(--admin-text-subtle); margin-top: 6px;">
维护帮助分类消息事件工单文案消息中心顶部 copy 与报告风险提示默认内容
</div>
</div>
<el-button type="primary" :loading="saving" @click="saveMeta">保存分类与文案</el-button>
</div>
<el-form label-position="top">
<el-divider content-position="left">帮助分类</el-divider>
<div v-for="(item, index) in metaForm.help_categories" :key="`help-category-${index}`" class="content-block">
<div class="content-block__header">
<div class="content-block__title">分类 {{ index + 1 }}</div>
<el-button link type="danger" @click="removeMetaItem('help_categories', index)">删除</el-button>
</div>
<el-row :gutter="16">
<el-col :span="6"><el-form-item label="分类编码"><el-input v-model="item.code" placeholder="service / report / shipping / support / all" /></el-form-item></el-col>
<el-col :span="6"><el-form-item label="分类名称"><el-input v-model="item.title" /></el-form-item></el-col>
<el-col :span="12"><el-form-item label="分类说明"><el-input v-model="item.desc" /></el-form-item></el-col>
</el-row>
</div>
<el-button plain @click="addMetaItem('help_categories')">新增帮助分类</el-button>
<el-divider content-position="left">工单类型文案</el-divider>
<div v-for="(item, index) in metaForm.ticket_types" :key="`ticket-type-${index}`" class="content-block">
<div class="content-block__header">
<div class="content-block__title">工单类型 {{ index + 1 }}</div>
<el-button link type="danger" @click="removeMetaItem('ticket_types', index)">删除</el-button>
</div>
<el-row :gutter="16">
<el-col :span="6"><el-form-item label="类型编码"><el-input v-model="item.code" placeholder="order_issue" /></el-form-item></el-col>
<el-col :span="6"><el-form-item label="标题"><el-input v-model="item.title" /></el-form-item></el-col>
<el-col :span="12"><el-form-item label="发起页提示"><el-input v-model="item.hint" placeholder="适合订单状态、支付、进度问题" /></el-form-item></el-col>
<el-col :span="24"><el-form-item label="快捷入口说明"><el-input v-model="item.quick_desc" placeholder="进度、状态、支付相关" /></el-form-item></el-col>
</el-row>
</div>
<el-button plain @click="addMetaItem('ticket_types')">新增工单类型</el-button>
<el-divider content-position="left">工单状态说明</el-divider>
<div v-for="(item, index) in metaForm.ticket_statuses" :key="`ticket-status-${index}`" class="content-block">
<div class="content-block__header">
<div class="content-block__title">状态 {{ index + 1 }}</div>
<el-button link type="danger" @click="removeMetaItem('ticket_statuses', index)">删除</el-button>
</div>
<el-row :gutter="16">
<el-col :span="6"><el-form-item label="状态编码"><el-input v-model="item.code" placeholder="pending" /></el-form-item></el-col>
<el-col :span="6"><el-form-item label="状态名称"><el-input v-model="item.title" /></el-form-item></el-col>
<el-col :span="12"><el-form-item label="状态说明"><el-input v-model="item.desc" placeholder="工单已提交,客服尚未正式开始处理。" /></el-form-item></el-col>
</el-row>
</div>
<el-button plain @click="addMetaItem('ticket_statuses')">新增工单状态</el-button>
<el-divider content-position="left">消息事件说明</el-divider>
<div v-for="(item, index) in metaForm.message_events" :key="`message-event-${index}`" class="content-block">
<div class="content-block__header">
<div class="content-block__title">消息事件 {{ index + 1 }}</div>
<el-button link type="danger" @click="removeMetaItem('message_events', index)">删除</el-button>
</div>
<el-row :gutter="16">
<el-col :span="8"><el-form-item label="事件编码"><el-input v-model="item.event_code" placeholder="order_created" /></el-form-item></el-col>
<el-col :span="8"><el-form-item label="事件名称"><el-input v-model="item.title" /></el-form-item></el-col>
<el-col :span="8"><el-form-item label="事件说明"><el-input v-model="item.desc" /></el-form-item></el-col>
</el-row>
</div>
<el-button plain @click="addMetaItem('message_events')">新增消息事件</el-button>
<el-divider content-position="left">消息中心顶部文案</el-divider>
<div class="content-block">
<el-row :gutter="16">
<el-col :span="8"><el-form-item label="标题"><el-input v-model="metaForm.message_page_copy.title" placeholder="服务提醒与处理进度" /></el-form-item></el-col>
<el-col :span="16"><el-form-item label="说明"><el-input v-model="metaForm.message_page_copy.desc" type="textarea" :rows="3" placeholder="这里会统一展示订单流转、补资料、报告出具和工单回复等关键通知。" /></el-form-item></el-col>
</el-row>
</div>
<el-divider content-position="left">报告风险提示默认文案</el-divider>
<div v-for="(item, index) in metaForm.report_risk_defaults" :key="`risk-default-${index}`" class="content-block">
<div class="content-block__header">
<div class="content-block__title">默认文案 {{ index + 1 }}</div>
<el-button link type="danger" @click="removeMetaItem('report_risk_defaults', index)">删除</el-button>
</div>
<el-row :gutter="16">
<el-col :span="6"><el-form-item label="报告类型"><el-input v-model="item.report_type" placeholder="appraisal / inspection" /></el-form-item></el-col>
<el-col :span="6"><el-form-item label="文案标题"><el-input v-model="item.title" placeholder="例如:正式鉴定报告" /></el-form-item></el-col>
<el-col :span="12"><el-form-item label="默认文案"><el-input v-model="item.text" type="textarea" :rows="3" /></el-form-item></el-col>
</el-row>
</div>
<el-button plain @click="addMetaItem('report_risk_defaults')">新增风险提示默认文案</el-button>
</el-form>
</el-card>
</template>
<style scoped>
.content-block {
margin-bottom: 18px;
padding: 16px 18px;
border: 1px solid var(--admin-border);
border-radius: 14px;
background: linear-gradient(180deg, rgba(255, 251, 244, 0.7) 0%, rgba(255, 255, 255, 0.96) 100%);
}
.content-block__header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.content-block__title {
font-size: 14px;
font-weight: 700;
color: var(--admin-text);
}
</style>

View File

@@ -0,0 +1,172 @@
<script setup lang="ts">
import { computed, onMounted, ref } from "vue";
import { ElMessage } from "element-plus";
import { adminApi, type AdminContentPolicyConfig, type AdminContentPolicyItem, type AdminHelpArticleItem } from "../../api/admin";
import { createPolicyItem, normalizePolicyConfig } from "./shared";
const loading = ref(false);
const saving = ref(false);
const articles = ref<AdminHelpArticleItem[]>([]);
const policyForm = ref<AdminContentPolicyConfig>({
legal_entries: [],
appraisal_agreements: [],
});
const helpArticleOptions = computed(() =>
articles.value.map((item) => ({
label: `${item.title}${item.category_text}`,
value: item.id,
})),
);
async function fetchPolicy() {
loading.value = true;
try {
const [policyResult, articleResult] = await Promise.all([
adminApi.getContentPolicy(),
adminApi.getHelpArticles(),
]);
policyForm.value = normalizePolicyConfig(policyResult.data.policy_config);
articles.value = articleResult.data.list;
} catch (error) {
console.error(error);
ElMessage.error("协议与说明加载失败");
} finally {
loading.value = false;
}
}
function addPolicyItem(section: "legal_entries" | "appraisal_agreements") {
policyForm.value[section].push(createPolicyItem());
}
function removePolicyItem(section: "legal_entries" | "appraisal_agreements", index: number) {
policyForm.value[section].splice(index, 1);
}
function bindPolicyArticle(item: AdminContentPolicyItem, articleId: number) {
item.article_id = Number(articleId || 0);
item.target_url = item.article_id > 0 ? `/pages/help/detail?id=${item.article_id}` : "";
}
async function savePolicy() {
saving.value = true;
try {
const response = await adminApi.saveContentPolicy(policyForm.value);
policyForm.value = normalizePolicyConfig(response.data.policy_config);
ElMessage.success("协议与说明已保存");
} catch (error) {
console.error(error);
ElMessage.error("协议与说明保存失败");
} finally {
saving.value = false;
}
}
onMounted(fetchPolicy);
</script>
<template>
<el-card class="panel-card" shadow="never" v-loading="loading">
<div class="filters-row" style="justify-content: space-between;">
<div>
<div style="font-size: 16px; font-weight: 700;">协议与说明</div>
<div style="color: var(--admin-text-subtle); margin-top: 6px;">
维护设置页说明入口以及下单确认页展示的服务协议鉴定须知与隐私政策
</div>
</div>
<el-button type="primary" :loading="saving" @click="savePolicy">保存协议与说明</el-button>
</div>
<el-form label-position="top">
<el-divider content-position="left">设置页说明入口</el-divider>
<div v-for="(item, index) in policyForm.legal_entries" :key="`legal-${index}`" class="content-block">
<div class="content-block__header">
<div class="content-block__title">入口 {{ index + 1 }}</div>
<el-button link type="danger" @click="removePolicyItem('legal_entries', index)">删除</el-button>
</div>
<el-row :gutter="16">
<el-col :span="6"><el-form-item label="编码"><el-input v-model="item.code" /></el-form-item></el-col>
<el-col :span="6"><el-form-item label="标题"><el-input v-model="item.title" /></el-form-item></el-col>
<el-col :span="12">
<el-form-item label="绑定文章">
<el-select
v-model="item.article_id"
clearable
filterable
style="width: 100%"
placeholder="请选择帮助中心文章"
@change="bindPolicyArticle(item, Number($event || 0))"
>
<el-option v-for="option in helpArticleOptions" :key="option.value" :label="option.label" :value="option.value" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="24">
<el-form-item label="详情页链接">
<el-input v-model="item.target_url" placeholder="/pages/help/detail?id=12" />
</el-form-item>
</el-col>
<el-col :span="24"><el-form-item label="说明"><el-input v-model="item.desc" /></el-form-item></el-col>
</el-row>
</div>
<el-button plain @click="addPolicyItem('legal_entries')">新增设置入口</el-button>
<el-divider content-position="left">下单确认协议</el-divider>
<div v-for="(item, index) in policyForm.appraisal_agreements" :key="`agreement-${index}`" class="content-block">
<div class="content-block__header">
<div class="content-block__title">协议 {{ index + 1 }}</div>
<el-button link type="danger" @click="removePolicyItem('appraisal_agreements', index)">删除</el-button>
</div>
<el-row :gutter="16">
<el-col :span="6"><el-form-item label="编码"><el-input v-model="item.code" /></el-form-item></el-col>
<el-col :span="6"><el-form-item label="标题"><el-input v-model="item.title" /></el-form-item></el-col>
<el-col :span="12">
<el-form-item label="绑定文章">
<el-select
v-model="item.article_id"
clearable
filterable
style="width: 100%"
placeholder="请选择帮助中心文章"
@change="bindPolicyArticle(item, Number($event || 0))"
>
<el-option v-for="option in helpArticleOptions" :key="option.value" :label="option.label" :value="option.value" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="24">
<el-form-item label="详情页链接">
<el-input v-model="item.target_url" placeholder="/pages/help/detail?id=12" />
</el-form-item>
</el-col>
<el-col :span="24"><el-form-item label="说明"><el-input v-model="item.desc" /></el-form-item></el-col>
</el-row>
</div>
<el-button plain @click="addPolicyItem('appraisal_agreements')">新增协议项</el-button>
</el-form>
</el-card>
</template>
<style scoped>
.content-block {
margin-bottom: 18px;
padding: 16px 18px;
border: 1px solid var(--admin-border);
border-radius: 14px;
background: linear-gradient(180deg, rgba(255, 251, 244, 0.7) 0%, rgba(255, 255, 255, 0.96) 100%);
}
.content-block__header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.content-block__title {
font-size: 14px;
font-weight: 700;
color: var(--admin-text);
}
</style>

View File

@@ -0,0 +1,157 @@
import type { AdminContentHomeConfig, AdminContentMetaConfig, AdminContentPolicyConfig, AdminContentPolicyItem, AdminHelpArticleItem, AdminHelpArticlePayload } from "../../api/admin";
export type HomeSectionKey = "service_entries" | "quick_entries" | "trust_metrics" | "trust_points" | "faqs";
export type PolicySectionKey = "legal_entries" | "appraisal_agreements";
export type MetaSectionKey = "help_categories" | "report_risk_defaults" | "ticket_types" | "ticket_statuses" | "message_events";
export type ContentTabKey = "home" | "policy" | "meta" | "articles";
export type ArticleFormState = {
id?: number;
category: AdminHelpArticlePayload["category"];
title: string;
summary: string;
keywordsText: string;
contentBlocksText: string;
is_recommended: boolean;
is_enabled: boolean;
sort_order: number;
};
export const contentTabs: Array<{ key: ContentTabKey; label: string; desc: string; routeName: string }> = [
{ key: "home", label: "首页与主页面", desc: "Banner、主页面背景、服务入口和信任信息。", routeName: "content-home" },
{ key: "policy", label: "协议与说明", desc: "设置页说明入口和下单确认协议。", routeName: "content-policy" },
{ key: "meta", label: "分类与文案", desc: "帮助分类、消息事件、工单文案和风险提示。", routeName: "content-meta" },
{ key: "articles", label: "帮助文章", desc: "帮助中心文章正文、推荐状态和排序。", routeName: "content-articles" },
];
export const serviceProviderOptions = [
{ label: "实物鉴定", value: "anxinyan" },
{ label: "中检鉴定", value: "zhongjian" },
];
export const quickCodeOptions = [
{ label: "发起鉴定", value: "start" },
{ label: "我的订单", value: "orders" },
{ label: "我的报告", value: "reports" },
{ label: "消息中心", value: "messages" },
];
export const articleCategoryOptions = [
{ label: "服务流程", value: "service" },
{ label: "报告验真", value: "report" },
{ label: "寄送物流", value: "shipping" },
{ label: "售后支持", value: "support" },
];
export function createHomeConfig(): AdminContentHomeConfig {
return {
banners: [{ title: "", subtitle: "", description: "", background_image_url: "" }],
page_visuals: {
order_background_image_url: "",
report_background_image_url: "",
},
service_entries: [],
category_visuals: [],
quick_entries: [],
trust_metrics: [],
trust_points: [],
faqs: [],
};
}
export function normalizeHomeConfig(config?: Partial<AdminContentHomeConfig>): AdminContentHomeConfig {
const banners = config?.banners?.length ? config.banners : [{ title: "", subtitle: "", description: "", background_image_url: "" }];
const pageVisuals: Partial<AdminContentHomeConfig["page_visuals"]> = config?.page_visuals || {};
return {
banners: banners.map((item) => ({
title: item.title || "",
subtitle: item.subtitle || "",
description: item.description || "",
background_image_url: item.background_image_url || "",
})),
page_visuals: {
order_background_image_url: pageVisuals.order_background_image_url || "",
report_background_image_url: pageVisuals.report_background_image_url || "",
},
service_entries: config?.service_entries || [],
category_visuals: (config?.category_visuals || []).map((item) => ({
category_name: item.category_name || "",
category_code: item.category_code || "",
image_url: item.image_url || "",
})),
quick_entries: config?.quick_entries || [],
trust_metrics: config?.trust_metrics || [],
trust_points: config?.trust_points || [],
faqs: config?.faqs || [],
};
}
export function createPolicyItem(): AdminContentPolicyItem {
return {
code: "",
title: "",
desc: "",
target_url: "",
article_id: 0,
};
}
export function parseHelpArticleId(targetUrl?: string) {
if (!targetUrl) {
return 0;
}
const matched = targetUrl.match(/\/pages\/help\/detail\?id=(\d+)/);
return matched ? Number(matched[1] || 0) : 0;
}
export function normalizePolicyItems(items?: Partial<AdminContentPolicyItem>[]) {
return (items || []).map((item) => ({
code: item.code || "",
title: item.title || "",
desc: item.desc || "",
target_url: item.target_url || "",
article_id: Number(item.article_id || parseHelpArticleId(item.target_url) || 0),
}));
}
export function normalizePolicyConfig(config?: Partial<AdminContentPolicyConfig>): AdminContentPolicyConfig {
return {
legal_entries: normalizePolicyItems(config?.legal_entries),
appraisal_agreements: normalizePolicyItems(config?.appraisal_agreements),
};
}
export function normalizeMetaConfig(config?: Partial<AdminContentMetaConfig>): AdminContentMetaConfig {
return {
help_categories: config?.help_categories || [],
report_risk_defaults: config?.report_risk_defaults || [],
ticket_types: config?.ticket_types || [],
ticket_statuses: config?.ticket_statuses || [],
message_events: config?.message_events || [],
message_page_copy: config?.message_page_copy || {
title: "",
desc: "",
},
};
}
export function resetArticleForm(target: ArticleFormState, row?: AdminHelpArticleItem) {
target.id = row?.id;
target.category = row?.category || "service";
target.title = row?.title || "";
target.summary = row?.summary || "";
target.keywordsText = row?.keywords?.join("\n") || "";
target.contentBlocksText = row?.content_blocks?.join("\n") || "";
target.is_recommended = row?.is_recommended || false;
target.is_enabled = row ? row.is_enabled : true;
target.sort_order = row?.sort_order || 0;
}
export function parseLines(value: string) {
return value
.split("\n")
.map((item) => item.trim())
.filter(Boolean);
}

View File

@@ -0,0 +1,512 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from "vue";
import { ElMessage, ElMessageBox } from "element-plus";
import {
adminApi,
type EnterpriseCustomer,
type EnterpriseCustomerApp,
type EnterpriseCustomerOrderRef,
type EnterpriseCustomerPayload,
type EnterpriseOrderEvent,
type EnterpriseWebhookDelivery,
} from "../../api/admin";
import OrderStatusTag from "../../components/OrderStatusTag.vue";
const loading = ref(false);
const detailLoading = ref(false);
const submitting = ref(false);
const appSubmitting = ref(false);
const drawerVisible = ref(false);
const customerDialogVisible = ref(false);
const appDialogVisible = ref(false);
const secretDialogVisible = ref(false);
const activeTab = ref("apps");
const keyword = ref("");
const status = ref("");
const appName = ref("默认应用");
const oneTimeSecret = ref("");
const customers = ref<EnterpriseCustomer[]>([]);
const currentCustomer = ref<EnterpriseCustomer | null>(null);
const apps = ref<EnterpriseCustomerApp[]>([]);
const orders = ref<EnterpriseCustomerOrderRef[]>([]);
const events = ref<EnterpriseOrderEvent[]>([]);
const deliveries = ref<EnterpriseWebhookDelivery[]>([]);
const customerForm = reactive<EnterpriseCustomerPayload>({
customer_name: "",
contact_name: "",
contact_mobile: "",
contact_email: "",
webhook_url: "",
webhook_enabled: false,
status: "enabled",
remark: "",
});
const statusOptions = [
{ label: "全部状态", value: "" },
{ label: "启用中", value: "enabled" },
{ label: "已停用", value: "disabled" },
];
const cards = computed(() => {
const enabled = customers.value.filter((item) => item.status === "enabled").length;
const appsTotal = customers.value.reduce((sum, item) => sum + (item.app_count || 0), 0);
const ordersTotal = customers.value.reduce((sum, item) => sum + (item.order_count || 0), 0);
const eventsTotal = customers.value.reduce((sum, item) => sum + (item.event_count || 0), 0);
return [
{ title: "客户总数", value: customers.value.length, desc: `${enabled} 个客户启用中` },
{ title: "应用 Key", value: appsTotal, desc: "客户可用开放接口应用" },
{ title: "推送订单", value: ordersTotal, desc: "已绑定的外部订单" },
{ title: "状态事件", value: eventsTotal, desc: "订单状态推送事件" },
];
});
async function fetchCustomers() {
loading.value = true;
try {
const response = await adminApi.getCustomers({
keyword: keyword.value,
status: status.value,
});
customers.value = response.data.list;
} catch (error) {
console.error(error);
ElMessage.error("客户列表加载失败");
} finally {
loading.value = false;
}
}
async function refreshDetail(customerId = currentCustomer.value?.id || 0) {
if (!customerId) {
return;
}
detailLoading.value = true;
try {
const [detailRes, ordersRes, eventsRes, deliveriesRes] = await Promise.all([
adminApi.getCustomerDetail(customerId),
adminApi.getCustomerOrders(customerId),
adminApi.getCustomerEvents(customerId),
adminApi.getCustomerDeliveries({ customer_id: customerId }),
]);
currentCustomer.value = detailRes.data.customer;
apps.value = detailRes.data.apps;
orders.value = ordersRes.data.list;
events.value = eventsRes.data.list;
deliveries.value = deliveriesRes.data.list;
} catch (error) {
console.error(error);
ElMessage.error("客户详情加载失败");
} finally {
detailLoading.value = false;
}
}
async function openDetail(row: EnterpriseCustomer) {
drawerVisible.value = true;
currentCustomer.value = row;
activeTab.value = "apps";
await refreshDetail(row.id);
}
function openCustomerDialog(row?: EnterpriseCustomer) {
if (row) {
customerForm.id = row.id;
customerForm.customer_name = row.customer_name;
customerForm.contact_name = row.contact_name;
customerForm.contact_mobile = row.contact_mobile;
customerForm.contact_email = row.contact_email;
customerForm.webhook_url = row.webhook_url;
customerForm.webhook_enabled = row.webhook_enabled;
customerForm.status = row.status;
customerForm.remark = row.remark;
} else {
customerForm.id = undefined;
customerForm.customer_name = "";
customerForm.contact_name = "";
customerForm.contact_mobile = "";
customerForm.contact_email = "";
customerForm.webhook_url = "";
customerForm.webhook_enabled = false;
customerForm.status = "enabled";
customerForm.remark = "";
}
customerDialogVisible.value = true;
}
async function submitCustomer() {
submitting.value = true;
try {
const response = await adminApi.saveCustomer({ ...customerForm });
ElMessage.success(customerForm.id ? "客户已更新" : "客户已创建");
customerDialogVisible.value = false;
await fetchCustomers();
if (drawerVisible.value && currentCustomer.value?.id === response.data.id) {
await refreshDetail(response.data.id);
}
} catch (error) {
console.error(error);
ElMessage.error("客户保存失败");
} finally {
submitting.value = false;
}
}
function openAppDialog() {
appName.value = "默认应用";
appDialogVisible.value = true;
}
async function submitApp() {
if (!currentCustomer.value) {
return;
}
appSubmitting.value = true;
try {
const response = await adminApi.createCustomerApp(currentCustomer.value.id, appName.value);
oneTimeSecret.value = response.data.app_secret;
secretDialogVisible.value = true;
appDialogVisible.value = false;
await refreshDetail(currentCustomer.value.id);
await fetchCustomers();
} catch (error) {
console.error(error);
ElMessage.error("应用 Key 创建失败");
} finally {
appSubmitting.value = false;
}
}
async function toggleApp(row: EnterpriseCustomerApp) {
const nextStatus = row.status === "enabled" ? "disabled" : "enabled";
try {
await adminApi.updateCustomerAppStatus(row.id, nextStatus);
ElMessage.success(nextStatus === "enabled" ? "应用已启用" : "应用已停用");
await refreshDetail();
} catch (error) {
console.error(error);
ElMessage.error("应用状态更新失败");
}
}
async function resetSecret(row: EnterpriseCustomerApp) {
try {
await ElMessageBox.confirm("重置后旧 Secret 将立即失效,新 Secret 只展示一次。确定继续吗?", "重置 Secret", {
type: "warning",
});
const response = await adminApi.resetCustomerAppSecret(row.id);
oneTimeSecret.value = response.data.app_secret;
secretDialogVisible.value = true;
await refreshDetail();
} catch (error) {
if (error !== "cancel") {
console.error(error);
ElMessage.error("Secret 重置失败");
}
}
}
async function resendEvent(row: EnterpriseOrderEvent) {
try {
const response = await adminApi.resendCustomerEvent(row.id);
ElMessage.success(response.data.sent ? "事件已补发成功" : "补发未成功,请查看推送记录");
await refreshDetail();
activeTab.value = "deliveries";
} catch (error) {
console.error(error);
ElMessage.error("事件补发失败");
}
}
function showEventDeliveries(row: EnterpriseOrderEvent) {
activeTab.value = "deliveries";
deliveries.value = deliveries.value.filter((item) => item.event_id === row.id);
adminApi.getCustomerDeliveries({ event_id: row.id })
.then((response) => {
deliveries.value = response.data.list;
})
.catch((error) => {
console.error(error);
ElMessage.error("推送记录加载失败");
});
}
onMounted(fetchCustomers);
</script>
<template>
<div v-loading="loading">
<div class="metric-grid" style="margin-bottom: 18px">
<div v-for="item in cards" :key="item.title" class="metric-card">
<div class="metric-card__label">{{ item.title }}</div>
<div class="metric-card__value">{{ item.value }}</div>
<div class="metric-card__desc">{{ item.desc }}</div>
</div>
</div>
<el-card class="panel-card" shadow="never">
<div class="filters-row" style="justify-content: space-between">
<div class="filters-row">
<el-input v-model="keyword" placeholder="搜索客户名称 / 客户ID / 联系人" clearable style="width: 320px" />
<el-select v-model="status" placeholder="客户状态" style="width: 150px">
<el-option v-for="item in statusOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
<el-button type="primary" @click="fetchCustomers">查询</el-button>
</div>
<el-button type="primary" @click="openCustomerDialog()">新增客户</el-button>
</div>
</el-card>
<el-card class="panel-card orders-table" shadow="never">
<el-table :data="customers" stripe>
<el-table-column prop="customer_name" label="客户名称" min-width="180" />
<el-table-column prop="customer_code" label="客户ID" min-width="190" />
<el-table-column label="联系人" min-width="180">
<template #default="{ row }">
<div>{{ row.contact_name || "-" }}</div>
<div style="color: var(--admin-text-subtle); font-size: 12px">{{ row.contact_mobile || row.contact_email || "-" }}</div>
</template>
</el-table-column>
<el-table-column label="Webhook" min-width="240">
<template #default="{ row }">
<el-tag :type="row.webhook_enabled ? 'success' : 'info'" round>{{ row.webhook_enabled ? "已启用" : "未启用" }}</el-tag>
<span class="inline-url">{{ row.webhook_url || "-" }}</span>
</template>
</el-table-column>
<el-table-column label="状态" min-width="110">
<template #default="{ row }">
<OrderStatusTag :status="row.status_text" />
</template>
</el-table-column>
<el-table-column prop="app_count" label="Key" min-width="80" />
<el-table-column prop="order_count" label="订单" min-width="80" />
<el-table-column prop="event_count" label="事件" min-width="80" />
<el-table-column prop="created_at" label="创建时间" min-width="170" />
<el-table-column label="操作" fixed="right" width="150">
<template #default="{ row }">
<el-button link type="primary" @click="openDetail(row)">详情</el-button>
<el-button link type="warning" @click="openCustomerDialog(row)">编辑</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
<el-drawer v-model="drawerVisible" size="78%" title="客户详情">
<div v-loading="detailLoading" v-if="currentCustomer">
<div class="detail-grid">
<div class="detail-card">
<div class="detail-card__title">客户资料</div>
<div class="detail-card__desc">
<div class="detail-label">客户名称 / ID</div>
<div class="detail-value">{{ currentCustomer.customer_name }} / {{ currentCustomer.customer_code }}</div>
</div>
<div class="detail-card__desc">
<div class="detail-label">联系人</div>
<div class="detail-value">{{ currentCustomer.contact_name || "-" }} / {{ currentCustomer.contact_mobile || "-" }}</div>
</div>
<div class="detail-card__desc">
<div class="detail-label">月结与虚拟用户</div>
<div class="detail-value">{{ currentCustomer.settlement_type_text }} / User #{{ currentCustomer.user_id || "-" }}</div>
</div>
</div>
<div class="detail-card">
<div class="detail-card__title">Webhook</div>
<div class="detail-card__desc">
<div class="detail-label">状态</div>
<div class="detail-value">{{ currentCustomer.webhook_enabled ? "已启用" : "未启用" }}</div>
</div>
<div class="detail-card__desc">
<div class="detail-label">URL</div>
<div class="detail-value detail-url">{{ currentCustomer.webhook_url || "-" }}</div>
</div>
<div class="detail-card__desc">
<el-button size="small" @click="openCustomerDialog(currentCustomer)">编辑配置</el-button>
</div>
</div>
</div>
<el-card class="panel-card" shadow="never" style="margin-top: 18px">
<el-tabs v-model="activeTab">
<el-tab-pane label="应用 Key" name="apps">
<div class="filters-row" style="justify-content: flex-end; margin-bottom: 12px">
<el-button type="primary" @click="openAppDialog">创建应用 Key</el-button>
</div>
<el-table :data="apps" stripe>
<el-table-column prop="app_name" label="应用名称" min-width="150" />
<el-table-column prop="app_key" label="App Key" min-width="240" />
<el-table-column prop="secret_last4" label="Secret 后四位" min-width="120" />
<el-table-column label="状态" min-width="110">
<template #default="{ row }">
<OrderStatusTag :status="row.status_text" />
</template>
</el-table-column>
<el-table-column prop="last_used_at" label="最近使用" min-width="170" />
<el-table-column prop="created_at" label="创建时间" min-width="170" />
<el-table-column label="操作" fixed="right" width="190">
<template #default="{ row }">
<el-button link type="primary" @click="toggleApp(row)">{{ row.status === "enabled" ? "停用" : "启用" }}</el-button>
<el-button link type="warning" @click="resetSecret(row)">重置 Secret</el-button>
</template>
</el-table-column>
</el-table>
</el-tab-pane>
<el-tab-pane label="客户订单" name="orders">
<el-table :data="orders" stripe>
<el-table-column prop="external_order_no" label="外部订单号" min-width="180" />
<el-table-column prop="order_no" label="我方订单号" min-width="180" />
<el-table-column prop="appraisal_no" label="鉴定单号" min-width="190" />
<el-table-column prop="product_name" label="商品" min-width="160" />
<el-table-column label="状态" min-width="140">
<template #default="{ row }">
<OrderStatusTag :status="row.display_status || row.order_status" />
</template>
</el-table-column>
<el-table-column prop="pay_amount" label="金额" min-width="90" />
<el-table-column prop="created_at" label="创建时间" min-width="170" />
</el-table>
</el-tab-pane>
<el-tab-pane label="状态事件" name="events">
<el-table :data="events" stripe>
<el-table-column prop="event_text" label="事件" min-width="140" />
<el-table-column prop="event_code" label="事件编码" min-width="170" />
<el-table-column prop="external_order_no" label="外部订单号" min-width="170" />
<el-table-column label="状态" min-width="140">
<template #default="{ row }">
<OrderStatusTag :status="row.status_text" />
</template>
</el-table-column>
<el-table-column prop="occurred_at" label="发生时间" min-width="170" />
<el-table-column label="操作" fixed="right" width="180">
<template #default="{ row }">
<el-button link type="primary" @click="showEventDeliveries(row)">推送记录</el-button>
<el-button link type="warning" @click="resendEvent(row)">补发</el-button>
</template>
</el-table-column>
</el-table>
</el-tab-pane>
<el-tab-pane label="Webhook 记录" name="deliveries">
<div class="filters-row" style="justify-content: flex-end; margin-bottom: 12px">
<el-button @click="refreshDetail()">查看全部记录</el-button>
</div>
<el-table :data="deliveries" stripe>
<el-table-column prop="event_id" label="事件ID" min-width="90" />
<el-table-column label="推送状态" min-width="120">
<template #default="{ row }">
<OrderStatusTag :status="row.delivery_status_text" />
</template>
</el-table-column>
<el-table-column prop="attempt_no" label="次数" min-width="80" />
<el-table-column prop="http_status" label="HTTP" min-width="90" />
<el-table-column prop="webhook_url" label="Webhook URL" min-width="260" />
<el-table-column prop="error_message" label="错误" min-width="220" />
<el-table-column prop="sent_at" label="发送时间" min-width="170" />
<el-table-column label="人工" min-width="80">
<template #default="{ row }">{{ row.is_manual ? "是" : "否" }}</template>
</el-table-column>
</el-table>
</el-tab-pane>
</el-tabs>
</el-card>
</div>
</el-drawer>
<el-dialog v-model="customerDialogVisible" :title="customerForm.id ? '编辑客户' : '新增客户'" width="680px">
<el-form label-position="top">
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="客户名称">
<el-input v-model="customerForm.customer_name" placeholder="请输入客户名称" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="客户状态">
<el-radio-group v-model="customerForm.status">
<el-radio value="enabled">启用</el-radio>
<el-radio value="disabled">停用</el-radio>
</el-radio-group>
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="联系人">
<el-input v-model="customerForm.contact_name" placeholder="联系人姓名" />
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="联系电话">
<el-input v-model="customerForm.contact_mobile" placeholder="联系人手机号" />
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="联系邮箱">
<el-input v-model="customerForm.contact_email" placeholder="联系人邮箱" />
</el-form-item>
</el-col>
<el-col :span="24">
<el-form-item label="Webhook URL">
<el-input v-model="customerForm.webhook_url" placeholder="https://customer.example.com/webhook" />
</el-form-item>
</el-col>
<el-col :span="24">
<el-form-item label="Webhook 开关">
<el-switch v-model="customerForm.webhook_enabled" active-text="启用" inactive-text="停用" />
</el-form-item>
</el-col>
<el-col :span="24">
<el-form-item label="备注">
<el-input v-model="customerForm.remark" type="textarea" :rows="3" placeholder="客户协作备注" />
</el-form-item>
</el-col>
</el-row>
</el-form>
<template #footer>
<el-button @click="customerDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="submitting" @click="submitCustomer">保存</el-button>
</template>
</el-dialog>
<el-dialog v-model="appDialogVisible" title="创建应用 Key" width="460px">
<el-form label-position="top">
<el-form-item label="应用名称">
<el-input v-model="appName" placeholder="例如 生产环境 / 测试环境" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="appDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="appSubmitting" @click="submitApp">创建</el-button>
</template>
</el-dialog>
<el-dialog v-model="secretDialogVisible" title="应用 Secret" width="620px">
<el-alert type="warning" show-icon :closable="false" title="Secret 只展示一次,关闭后无法再次查看。" />
<el-input v-model="oneTimeSecret" readonly style="margin-top: 16px" />
<template #footer>
<el-button type="primary" @click="secretDialogVisible = false">已保存</el-button>
</template>
</el-dialog>
</div>
</template>
<style scoped>
.inline-url {
display: inline-block;
max-width: 170px;
margin-left: 8px;
overflow: hidden;
color: var(--admin-text-subtle);
font-size: 12px;
text-overflow: ellipsis;
vertical-align: middle;
white-space: nowrap;
}
.detail-url {
overflow-wrap: anywhere;
}
</style>

View File

@@ -0,0 +1,31 @@
<script setup lang="ts">
import { onMounted, ref } from "vue";
import { adminApi, type DashboardCard } from "../../api/admin";
import { ElMessage } from "element-plus";
const cards = ref<DashboardCard[]>([]);
const loading = ref(false);
onMounted(async () => {
loading.value = true;
try {
const response = await adminApi.getDashboard();
cards.value = response.data.cards;
} catch (error) {
console.error(error);
ElMessage.error("工作台数据加载失败");
} finally {
loading.value = false;
}
});
</script>
<template>
<div v-loading="loading" class="metric-grid">
<div v-for="item in cards" :key="item.title" class="metric-card">
<div class="metric-card__label">{{ item.title }}</div>
<div class="metric-card__value">{{ item.value }}</div>
<div class="metric-card__desc">{{ item.desc }}</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,57 @@
<script setup lang="ts">
import { reactive, ref } from "vue";
import { ElMessage } from "element-plus";
import { adminApi } from "../../api/admin";
import { setAdminInfo, setAdminToken } from "../../utils/auth";
import { goToAdminHome } from "../../utils/navigation";
const loading = ref(false);
const form = reactive({
mobile: "",
password: "",
});
async function submitLogin() {
if (!form.mobile.trim() || !form.password.trim()) {
ElMessage.warning("请输入手机号和密码");
return;
}
loading.value = true;
try {
const response = await adminApi.login(form.mobile.trim(), form.password.trim());
setAdminToken(response.data.token);
setAdminInfo(response.data.admin_info);
ElMessage.success("登录成功");
goToAdminHome();
} catch (error: any) {
console.error(error);
ElMessage.error(error?.message || error?.payload?.message || "登录失败");
} finally {
loading.value = false;
}
}
</script>
<template>
<div class="login-page">
<div class="login-card">
<div class="login-card__eyebrow">安心验后台</div>
<div class="login-card__title">管理员登录</div>
<div class="login-card__desc">进入订单履约报告审核用户管理和系统配置中心</div>
<el-form label-position="top" @submit.prevent>
<el-form-item label="管理员手机号">
<el-input v-model="form.mobile" placeholder="请输入管理员手机号" />
</el-form-item>
<el-form-item label="登录密码">
<el-input v-model="form.password" type="password" show-password placeholder="请输入登录密码" @keyup.enter="submitLogin" />
</el-form-item>
</el-form>
<el-button type="primary" class="login-card__action" :loading="loading" @click="submitLogin">
{{ loading ? "登录中..." : "进入后台" }}
</el-button>
</div>
</div>
</template>

View File

@@ -0,0 +1,354 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from "vue";
import { ElMessage, ElMessageBox } from "element-plus";
import { adminApi, type AdminMaterialBatchDetail, type AdminMaterialBatchItem, type AdminMaterialTagCode } from "../../api/admin";
import OrderStatusTag from "../../components/OrderStatusTag.vue";
const loading = ref(false);
const creating = ref(false);
const downloadingId = ref<number | null>(null);
const detailLoading = ref(false);
const createDialogVisible = ref(false);
const detailDrawerVisible = ref(false);
const detailKeyword = ref("");
const keyword = ref("");
const qrUrl = ref("");
const verifyCode = ref("");
const dateRange = ref<[string, string] | null>(null);
const batches = ref<AdminMaterialBatchItem[]>([]);
const detail = ref<AdminMaterialBatchDetail | null>(null);
const createForm = reactive({
count: 100,
remark: "",
});
const stats = computed(() => {
const totalCodes = batches.value.reduce((sum, item) => sum + item.total_count, 0);
const totalBound = batches.value.reduce((sum, item) => sum + item.bound_count, 0);
const totalDownloads = batches.value.reduce((sum, item) => sum + item.download_count, 0);
return [
{ title: "批次数", value: batches.value.length, desc: "当前筛选结果内的物料批次" },
{ title: "二维码数", value: totalCodes, desc: "已生成的吊牌二维码链接" },
{ title: "已绑定", value: totalBound, desc: "已关联鉴定报告的吊牌" },
{ title: "下载次数", value: totalDownloads, desc: "Excel 打包下载总次数" },
];
});
function buildQueryParams() {
return {
keyword: keyword.value.trim(),
qr_url: qrUrl.value.trim(),
verify_code: verifyCode.value.trim(),
date_start: dateRange.value?.[0] || "",
date_end: dateRange.value?.[1] || "",
};
}
async function fetchBatches() {
loading.value = true;
try {
const response = await adminApi.getMaterialBatches(buildQueryParams());
batches.value = response.data.list;
} catch (error) {
console.error(error);
ElMessage.error("物料批次加载失败");
} finally {
loading.value = false;
}
}
function resetFilters() {
keyword.value = "";
qrUrl.value = "";
verifyCode.value = "";
dateRange.value = null;
fetchBatches();
}
async function createBatch() {
const count = Number(createForm.count);
if (!Number.isInteger(count) || count < 1 || count > 10000) {
ElMessage.warning("链接数量需为 1-10000 的整数");
return;
}
creating.value = true;
try {
await adminApi.createMaterialBatch({
count,
remark: createForm.remark.trim(),
});
ElMessage.success("物料批次已生成");
createDialogVisible.value = false;
createForm.count = 100;
createForm.remark = "";
await fetchBatches();
} catch (error: any) {
console.error(error);
ElMessage.error(error?.message || "物料批次生成失败");
} finally {
creating.value = false;
}
}
async function downloadBatch(row: Pick<AdminMaterialBatchItem, "id" | "batch_no">) {
try {
await ElMessageBox.confirm("将打包下载完整批次的二维码链接与验真编码,并记录一次下载次数。", "下载物料批次", {
type: "warning",
confirmButtonText: "确认下载",
cancelButtonText: "取消",
});
} catch {
return;
}
downloadingId.value = row.id;
try {
const blob = await adminApi.downloadMaterialBatch(row.id);
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = `material-batch-${row.batch_no}.xlsx`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
ElMessage.success("物料批次已下载");
await fetchBatches();
if (detail.value?.batch.id === row.id) {
await loadDetail(row.id);
}
} catch (error: any) {
console.error(error);
ElMessage.error(error?.message || "物料批次下载失败");
} finally {
downloadingId.value = null;
}
}
async function loadDetail(id: number) {
detailLoading.value = true;
try {
const response = await adminApi.getMaterialBatchDetail(id, detailKeyword.value.trim());
detail.value = response.data;
} catch (error) {
console.error(error);
ElMessage.error("批次详情加载失败");
} finally {
detailLoading.value = false;
}
}
async function openDetail(row: AdminMaterialBatchItem) {
detailKeyword.value = "";
detailDrawerVisible.value = true;
await loadDetail(row.id);
}
async function copyText(value: string, label: string) {
if (!value) {
ElMessage.warning(`${label}为空`);
return;
}
try {
await navigator.clipboard.writeText(value);
ElMessage.success(`${label}已复制`);
} catch {
const input = document.createElement("textarea");
input.value = value;
document.body.appendChild(input);
input.select();
document.execCommand("copy");
document.body.removeChild(input);
ElMessage.success(`${label}已复制`);
}
}
function openReport(row: AdminMaterialTagCode) {
if (!row.report_id) return;
window.location.hash = `#/reports?report_id=${row.report_id}`;
}
onMounted(fetchBatches);
</script>
<template>
<div v-loading="loading">
<div class="metric-grid" style="margin-bottom: 18px">
<div v-for="item in stats" :key="item.title" class="metric-card">
<div class="metric-card__label">{{ item.title }}</div>
<div class="metric-card__value">{{ item.value }}</div>
<div class="metric-card__desc">{{ item.desc }}</div>
</div>
</div>
<el-card class="panel-card" shadow="never">
<div class="filters-row" style="justify-content: space-between;">
<div class="filters-row">
<el-date-picker
v-model="dateRange"
type="daterange"
value-format="YYYY-MM-DD"
start-placeholder="开始日期"
end-placeholder="结束日期"
style="width: 260px"
/>
<el-input v-model="keyword" placeholder="搜索二维码链接 / token / 验真编码" clearable style="width: 320px" />
<el-input v-model="qrUrl" placeholder="二维码链接" clearable style="width: 260px" />
<el-input v-model="verifyCode" placeholder="验真编码" clearable style="width: 160px" />
<el-button type="primary" @click="fetchBatches">查询</el-button>
<el-button @click="resetFilters">重置</el-button>
</div>
<el-button type="primary" @click="createDialogVisible = true">批量建码</el-button>
</div>
</el-card>
<el-card class="panel-card orders-table" shadow="never">
<el-table :data="batches" stripe row-key="id">
<el-table-column prop="batch_no" label="批次号" min-width="180" />
<el-table-column prop="total_count" label="链接数量" min-width="100" />
<el-table-column label="绑定进度" min-width="130">
<template #default="{ row }">{{ row.bound_count }} / {{ row.total_count }}</template>
</el-table-column>
<el-table-column prop="download_count" label="下载次数" min-width="100" />
<el-table-column prop="created_by_name" label="创建人" min-width="110" />
<el-table-column prop="created_at" label="创建时间" min-width="170" />
<el-table-column prop="last_downloaded_at" label="最近下载" min-width="170" />
<el-table-column prop="remark" label="备注" min-width="220" show-overflow-tooltip />
<el-table-column label="命中条码" min-width="360">
<template #default="{ row }">
<div v-if="row.matched_codes.length" class="material-match-list">
<div v-for="item in row.matched_codes" :key="item.id" class="material-match-item">
<div class="material-match-item__main">{{ item.qr_url }}</div>
<div class="material-match-item__meta">
验真编码 {{ item.verify_code }} · 扫码 {{ item.scan_count }} · 验真 {{ item.verify_count }}
</div>
</div>
</div>
<span v-else style="color: var(--admin-text-subtle);">-</span>
</template>
</el-table-column>
<el-table-column label="操作" fixed="right" width="210">
<template #default="{ row }">
<el-button link type="primary" @click="openDetail(row)">查看详情</el-button>
<el-button link type="success" :loading="downloadingId === row.id" @click="downloadBatch(row)">下载 Excel</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
<el-dialog v-model="createDialogVisible" title="批量建码" width="520px">
<el-form label-position="top">
<el-form-item label="创建链接数量">
<el-input-number v-model="createForm.count" :min="1" :max="10000" :step="100" style="width: 100%" />
</el-form-item>
<el-form-item label="备注">
<el-input v-model="createForm.remark" type="textarea" :rows="4" maxlength="500" show-word-limit placeholder="可填写生产用途、工厂批次或内部说明" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="createDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="creating" @click="createBatch">提交建码</el-button>
</template>
</el-dialog>
<el-drawer v-model="detailDrawerVisible" size="72%" title="物料批次详情">
<div v-loading="detailLoading" v-if="detail" class="material-detail">
<div class="detail-grid">
<div class="detail-card">
<div class="detail-card__title">批次信息</div>
<div class="detail-card__desc">
<div class="detail-label">批次号</div>
<div class="detail-value">{{ detail.batch.batch_no }}</div>
</div>
<div class="detail-card__desc">
<div class="detail-label">数量 / 下载次数</div>
<div class="detail-value">{{ detail.batch.total_count }} / {{ detail.batch.download_count }}</div>
</div>
</div>
<div class="detail-card">
<div class="detail-card__title">生产备注</div>
<div class="detail-card__desc">
<div class="detail-label">备注</div>
<div class="detail-value">{{ detail.batch.remark || "-" }}</div>
</div>
<div class="detail-card__desc">
<div class="detail-label">最近下载</div>
<div class="detail-value">{{ detail.batch.last_downloaded_at || "-" }}</div>
</div>
</div>
</div>
<el-card class="panel-card" shadow="never" style="margin-top: 18px">
<div class="filters-row" style="justify-content: space-between;">
<div class="filters-row">
<el-input v-model="detailKeyword" placeholder="筛选二维码链接 / token / 验真编码" clearable style="width: 340px" />
<el-button type="primary" @click="loadDetail(detail.batch.id)">筛选</el-button>
</div>
<el-button type="success" :loading="downloadingId === detail.batch.id" @click="downloadBatch(detail.batch)">下载 Excel</el-button>
</div>
</el-card>
<el-card class="panel-card orders-table" shadow="never">
<el-table :data="detail.codes" stripe>
<el-table-column prop="qr_url" label="二维码链接" min-width="360">
<template #default="{ row }">
<div style="word-break: break-all;">{{ row.qr_url }}</div>
<el-button link type="primary" @click="copyText(row.qr_url, '二维码链接')">复制</el-button>
</template>
</el-table-column>
<el-table-column prop="verify_code" label="验真编码" min-width="120" />
<el-table-column label="绑定状态" min-width="120">
<template #default="{ row }">
<OrderStatusTag :status="row.bind_status_text" />
</template>
</el-table-column>
<el-table-column label="关联报告编号" min-width="180">
<template #default="{ row }">
<el-button v-if="row.report_id" link type="primary" @click="openReport(row)">{{ row.report_no }}</el-button>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column prop="scan_count" label="扫码次数" min-width="100" />
<el-table-column prop="verify_count" label="验真次数" min-width="100" />
<el-table-column prop="bound_by_name" label="绑定人" min-width="110" />
<el-table-column prop="bound_at" label="绑定时间" min-width="170" />
</el-table>
</el-card>
</div>
</el-drawer>
</div>
</template>
<style scoped>
.material-match-list {
display: grid;
gap: 8px;
}
.material-match-item {
padding: 8px 10px;
border: 1px solid var(--admin-border);
border-radius: 8px;
background: #fffdfa;
}
.material-match-item__main {
word-break: break-all;
color: var(--admin-text-main);
font-size: 12px;
}
.material-match-item__meta {
margin-top: 4px;
color: var(--admin-text-subtle);
font-size: 12px;
}
.material-detail {
display: grid;
gap: 0;
}
</style>

View File

@@ -0,0 +1,196 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from "vue";
import { ElMessage } from "element-plus";
import { adminApi, type AdminMessageLogItem, type AdminMessageOverviewCard, type AdminMessageTemplateItem, type AdminMessageTemplatePayload } from "../../api/admin";
import OrderStatusTag from "../../components/OrderStatusTag.vue";
const loading = ref(false);
const cards = ref<AdminMessageOverviewCard[]>([]);
const templates = ref<AdminMessageTemplateItem[]>([]);
const logs = ref<AdminMessageLogItem[]>([]);
const templateDialogVisible = ref(false);
const templateSubmitting = ref(false);
const messageEventOptions = ref<Array<{ event_code: string; title: string; desc: string }>>([]);
const templateForm = reactive<AdminMessageTemplatePayload>({
template_name: "",
template_code: "",
channel: "inbox",
event_code: "order_created",
title: "",
content: "",
is_enabled: true,
});
const currentEventDesc = computed(
() => messageEventOptions.value.find((item) => item.event_code === templateForm.event_code)?.desc || "",
);
function eventTitle(eventCode: string) {
return messageEventOptions.value.find((item) => item.event_code === eventCode)?.title || eventCode;
}
async function fetchAll() {
loading.value = true;
try {
const [overviewRes, templatesRes, logsRes, metaRes] = await Promise.all([
adminApi.getMessageOverview(),
adminApi.getMessageTemplates(),
adminApi.getMessageLogs(),
adminApi.getContentMeta(),
]);
cards.value = overviewRes.data.cards;
templates.value = templatesRes.data.list;
logs.value = logsRes.data.list;
messageEventOptions.value = metaRes.data.meta_config.message_events;
} catch (error) {
console.error(error);
ElMessage.error("消息中心数据加载失败");
} finally {
loading.value = false;
}
}
function openTemplateDialog(row?: AdminMessageTemplateItem) {
if (row) {
templateForm.id = row.id;
templateForm.template_name = row.template_name;
templateForm.template_code = row.template_code;
templateForm.channel = row.channel;
templateForm.event_code = row.event_code;
templateForm.title = row.title;
templateForm.content = row.content;
templateForm.is_enabled = row.is_enabled;
} else {
templateForm.id = undefined;
templateForm.template_name = "";
templateForm.template_code = "";
templateForm.channel = "inbox";
templateForm.event_code = "order_created";
templateForm.title = "";
templateForm.content = "";
templateForm.is_enabled = true;
}
templateDialogVisible.value = true;
}
async function submitTemplate() {
templateSubmitting.value = true;
try {
await adminApi.saveMessageTemplate({ ...templateForm });
ElMessage.success(templateForm.id ? "模板更新成功" : "模板创建成功");
templateDialogVisible.value = false;
await fetchAll();
} catch (error) {
console.error(error);
ElMessage.error("消息模板保存失败");
} finally {
templateSubmitting.value = false;
}
}
onMounted(fetchAll);
</script>
<template>
<div v-loading="loading">
<div class="metric-grid" style="margin-bottom: 18px">
<div v-for="item in cards" :key="item.title" class="metric-card">
<div class="metric-card__label">{{ item.title }}</div>
<div class="metric-card__value">{{ item.value }}</div>
<div class="metric-card__desc">{{ item.desc }}</div>
</div>
</div>
<el-card class="panel-card" shadow="never">
<el-tabs>
<el-tab-pane label="模板列表">
<div class="filters-row" style="margin-bottom: 16px">
<el-button type="primary" @click="openTemplateDialog()">新增模板</el-button>
</div>
<el-table :data="templates" stripe>
<el-table-column prop="template_name" label="模板名称" min-width="180" />
<el-table-column prop="template_code" label="模板编码" min-width="180" />
<el-table-column prop="channel_text" label="发送渠道" min-width="140" />
<el-table-column label="触发事件" min-width="180">
<template #default="{ row }">
{{ eventTitle(row.event_code) }}
</template>
</el-table-column>
<el-table-column prop="title" label="标题" min-width="180" />
<el-table-column label="状态" min-width="100">
<template #default="{ row }">
<OrderStatusTag :status="row.is_enabled ? '已启用' : '未启用'" />
</template>
</el-table-column>
<el-table-column label="操作" fixed="right" width="100">
<template #default="{ row }">
<el-button link type="primary" @click="openTemplateDialog(row)">编辑</el-button>
</template>
</el-table-column>
</el-table>
</el-tab-pane>
<el-tab-pane label="发送记录">
<el-table :data="logs" stripe>
<el-table-column prop="template_name" label="模板名称" min-width="180" />
<el-table-column prop="channel_text" label="发送渠道" min-width="120" />
<el-table-column prop="biz_type" label="业务类型" min-width="120" />
<el-table-column prop="biz_id" label="业务ID" min-width="100" />
<el-table-column label="发送状态" min-width="120">
<template #default="{ row }">
<OrderStatusTag :status="row.status_text" />
</template>
</el-table-column>
<el-table-column prop="sent_at" label="发送时间" min-width="170" />
<el-table-column prop="fail_reason" label="失败原因" min-width="220" />
</el-table>
</el-tab-pane>
</el-tabs>
</el-card>
<el-dialog v-model="templateDialogVisible" :title="templateForm.id ? '编辑消息模板' : '新增消息模板'" width="620px">
<el-form label-position="top">
<el-form-item label="模板名称">
<el-input v-model="templateForm.template_name" placeholder="请输入模板名称" />
</el-form-item>
<el-form-item label="模板编码">
<el-input v-model="templateForm.template_code" placeholder="请输入模板编码" />
</el-form-item>
<el-form-item label="发送渠道">
<el-radio-group v-model="templateForm.channel">
<el-radio value="inbox">站内消息</el-radio>
<el-radio value="sms">短信</el-radio>
<el-radio value="wechat_subscribe">微信订阅消息</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="触发事件">
<el-select v-model="templateForm.event_code" style="width: 100%">
<el-option
v-for="item in messageEventOptions"
:key="item.event_code"
:label="item.title"
:value="item.event_code"
/>
</el-select>
<div style="margin-top: 6px; color: var(--admin-text-subtle); font-size: 12px;">
{{ currentEventDesc }}
</div>
</el-form-item>
<el-form-item label="标题">
<el-input v-model="templateForm.title" placeholder="请输入消息标题" />
</el-form-item>
<el-form-item label="内容">
<el-input v-model="templateForm.content" type="textarea" :rows="5" placeholder="请输入模板内容" />
</el-form-item>
<el-form-item label="是否启用">
<el-switch v-model="templateForm.is_enabled" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="templateDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="templateSubmitting" @click="submitTemplate">保存</el-button>
</template>
</el-dialog>
</div>
</template>

View File

@@ -0,0 +1,814 @@
<script setup lang="ts">
import { computed, onMounted, ref } from "vue";
import { ElMessage, ElMessageBox } from "element-plus";
import { adminApi, type AdminOrderDetail, type AdminOrderListItem, type AdminOrderWarehouseOption } from "../../api/admin";
import OrderStatusTag from "../../components/OrderStatusTag.vue";
const loading = ref(false);
const detailLoading = ref(false);
const drawerVisible = ref(false);
const receiveSubmitting = ref(false);
const returnReceiveSubmitting = ref(false);
const warehouseSubmitting = ref(false);
const warehouseDialogVisible = ref(false);
const warehouseOptionsLoading = ref(false);
const warehouseOptions = ref<AdminOrderWarehouseOption[]>([]);
const selectedWarehouseId = ref(0);
const returnDialogVisible = ref(false);
const returnSubmitting = ref(false);
const returnExpressCompany = ref("");
const returnTrackingNo = ref("");
const keyword = ref("");
const serviceProvider = ref("");
const status = ref("");
const sourceChannel = ref("");
const orders = ref<AdminOrderListItem[]>([]);
const detail = ref<AdminOrderDetail | null>(null);
const providerOptions = [
{ label: "全部服务", value: "" },
{ label: "实物鉴定", value: "anxinyan" },
{ label: "中检鉴定", value: "zhongjian" },
];
const statusOptions = [
{ label: "全部状态", value: "" },
{ label: "待补资料", value: "pending_supplement" },
{ label: "待寄送", value: "pending_shipping" },
{ label: "鉴定中", value: "in_first_review" },
{ label: "待寄回", value: "report_published" },
{ label: "回寄途中", value: "returning" },
{ label: "已完成签收", value: "completed_signed" },
];
const sourceChannelOptions = [
{ label: "全部渠道", value: "" },
{ label: "小程序", value: "mini_program" },
{ label: "H5", value: "h5" },
{ label: "大客户推送订单", value: "enterprise_push" },
];
const usageStatusMap: Record<string, string> = {
new: "全新未使用",
light_use: "轻微使用痕迹",
used: "长期使用",
};
const usageStatusText = computed(() => {
const value = detail.value?.extra_info.usage_status || "";
return value ? usageStatusMap[value] || value : "-";
});
const productTitle = computed(() => {
if (!detail.value) {
return "待完善物品信息";
}
return detail.value.product_info.product_name || "待完善物品信息";
});
const productMetaText = computed(() => {
if (!detail.value) {
return "物品信息待完善";
}
const parts = [
detail.value.product_info.category_name,
detail.value.product_info.brand_name,
].filter(Boolean);
return parts.length ? parts.join(" / ") : "物品信息待完善";
});
const canMarkReceived = computed(() => {
if (!detail.value) {
return false;
}
if (detail.value.order_info.can_mark_received) {
return true;
}
return (
detail.value.order_info.order_status === "pending_shipping" &&
Boolean(detail.value.logistics_info?.tracking_no) &&
detail.value.logistics_info?.tracking_status !== "received"
);
});
const logisticsActionText = computed(() => {
if (!detail.value?.logistics_info) {
return "";
}
return canMarkReceived.value ? "用户已提交/寄出,待鉴定中心签收" : detail.value.logistics_info.tracking_status_text;
});
const canSubmitReturnLogistics = computed(() => Boolean(detail.value?.order_info.can_submit_return_logistics));
const returnLogisticsBlockReason = computed(() => detail.value?.order_info.return_logistics_block_reason || "");
const canMarkReturnReceived = computed(() => Boolean(detail.value?.order_info.can_mark_return_received));
async function fetchOrders() {
loading.value = true;
try {
const response = await adminApi.getOrders({
keyword: keyword.value,
service_provider: serviceProvider.value,
status: status.value,
source_channel: sourceChannel.value,
});
orders.value = response.data.list;
} catch (error) {
console.error(error);
ElMessage.error("订单列表加载失败");
} finally {
loading.value = false;
}
}
async function openDetail(row: AdminOrderListItem) {
detailLoading.value = true;
drawerVisible.value = true;
try {
const response = await adminApi.getOrderDetail(row.id);
detail.value = response.data;
} catch (error) {
console.error(error);
ElMessage.error("订单详情加载失败");
} finally {
detailLoading.value = false;
}
}
async function reloadDetail() {
if (!detail.value) return;
detailLoading.value = true;
try {
const response = await adminApi.getOrderDetail(detail.value.order_info.id);
detail.value = response.data;
} catch (error) {
console.error(error);
ElMessage.error("订单详情刷新失败");
} finally {
detailLoading.value = false;
}
}
async function markReceived() {
if (!detail.value) return;
receiveSubmitting.value = true;
try {
const response = await adminApi.receiveOrderLogistics(detail.value.order_info.id);
ElMessage.success(response.message || "已标记签收");
await reloadDetail();
await fetchOrders();
} catch (error) {
console.error(error);
ElMessage.error("标记签收失败");
} finally {
receiveSubmitting.value = false;
}
}
async function openWarehouseDialog() {
if (!detail.value) return;
warehouseOptionsLoading.value = true;
warehouseDialogVisible.value = true;
selectedWarehouseId.value = detail.value.shipping_target?.warehouse_id || 0;
try {
const response = await adminApi.getOrderWarehouseOptions(detail.value.order_info.id);
warehouseOptions.value = response.data.list;
} catch (error) {
console.error(error);
ElMessage.error("仓库列表加载失败");
} finally {
warehouseOptionsLoading.value = false;
}
}
async function submitWarehouseReassign() {
if (!detail.value || !selectedWarehouseId.value) {
ElMessage.warning("请先选择一个目标仓库");
return;
}
try {
await ElMessageBox.confirm("改派后,用户寄送页将展示新的收货仓库地址。确定继续吗?", "改派仓库", {
type: "warning",
confirmButtonText: "确认改派",
cancelButtonText: "取消",
});
} catch {
return;
}
warehouseSubmitting.value = true;
try {
const response = await adminApi.reassignOrderWarehouse(detail.value.order_info.id, selectedWarehouseId.value);
ElMessage.success(response.message || "仓库已改派");
warehouseDialogVisible.value = false;
await reloadDetail();
} catch (error) {
console.error(error);
ElMessage.error("仓库改派失败");
} finally {
warehouseSubmitting.value = false;
}
}
function openReturnDialog() {
if (!detail.value) return;
if (!canSubmitReturnLogistics.value) {
ElMessage.warning(returnLogisticsBlockReason.value || "当前订单暂不支持登记回寄运单");
return;
}
returnExpressCompany.value = detail.value.return_logistics?.express_company || "";
returnTrackingNo.value = detail.value.return_logistics?.tracking_no || "";
returnDialogVisible.value = true;
}
async function submitReturnLogistics() {
if (!canSubmitReturnLogistics.value) {
ElMessage.warning(returnLogisticsBlockReason.value || "当前订单暂不支持登记回寄运单");
return;
}
if (!detail.value || !returnExpressCompany.value.trim() || !returnTrackingNo.value.trim()) {
ElMessage.warning("请完整填写回寄快递公司和运单号");
return;
}
returnSubmitting.value = true;
try {
const response = await adminApi.saveOrderReturnLogistics({
id: detail.value.order_info.id,
express_company: returnExpressCompany.value.trim(),
tracking_no: returnTrackingNo.value.trim(),
});
ElMessage.success(response.message || "回寄运单已登记");
returnDialogVisible.value = false;
await reloadDetail();
await fetchOrders();
} catch (error) {
console.error(error);
ElMessage.error(error instanceof Error ? error.message : "回寄运单登记失败");
} finally {
returnSubmitting.value = false;
}
}
async function markReturnReceived() {
if (!detail.value) return;
returnReceiveSubmitting.value = true;
try {
const response = await adminApi.receiveOrderReturnLogistics(detail.value.order_info.id);
ElMessage.success(response.message || "已标记用户签收");
await reloadDetail();
await fetchOrders();
} catch (error) {
console.error(error);
ElMessage.error("标记用户签收失败");
} finally {
returnReceiveSubmitting.value = false;
}
}
onMounted(fetchOrders);
</script>
<template>
<el-card class="panel-card" shadow="never">
<div class="filters-row">
<el-input v-model="keyword" placeholder="搜索订单号 / 鉴定单号 / 商品名称" clearable style="width: 320px" />
<el-select v-model="serviceProvider" placeholder="服务类型" style="width: 160px">
<el-option v-for="item in providerOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
<el-select v-model="status" placeholder="订单状态" style="width: 160px">
<el-option v-for="item in statusOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
<el-select v-model="sourceChannel" placeholder="下单渠道" style="width: 170px">
<el-option v-for="item in sourceChannelOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
<el-button type="primary" @click="fetchOrders">查询</el-button>
</div>
</el-card>
<el-card class="panel-card orders-table" shadow="never">
<el-table v-loading="loading" :data="orders" stripe>
<el-table-column prop="order_no" label="订单号" min-width="170" />
<el-table-column prop="appraisal_no" label="鉴定单号" min-width="180" />
<el-table-column prop="product_name" label="商品名称" min-width="220" />
<el-table-column prop="service_provider_text" label="服务类型" min-width="120" />
<el-table-column label="下单渠道" min-width="150">
<template #default="{ row }">
<span>{{ row.source_channel_text }}</span>
<div v-if="row.source_customer_id" class="table-subtext">客户ID{{ row.source_customer_id }}</div>
</template>
</el-table-column>
<el-table-column label="订单状态" min-width="150">
<template #default="{ row }">
<OrderStatusTag :status="row.display_status" />
</template>
</el-table-column>
<el-table-column prop="estimated_finish_time" label="预计完成时间" min-width="170" />
<el-table-column prop="pay_amount" label="金额" min-width="100">
<template #default="{ row }">¥{{ row.pay_amount }}</template>
</el-table-column>
<el-table-column prop="created_at" label="创建时间" min-width="170" />
<el-table-column label="操作" fixed="right" width="110">
<template #default="{ row }">
<el-button link type="primary" @click="openDetail(row)">查看详情</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
<el-drawer v-model="drawerVisible" size="68%" title="订单详情">
<div v-loading="detailLoading" v-if="detail" class="order-detail-shell">
<div class="detail-card order-detail-hero">
<div class="order-detail-hero__main">
<div class="order-detail-hero__eyebrow">订单履约工作区</div>
<div class="order-detail-hero__title">{{ productTitle }}</div>
<div class="order-detail-hero__meta">{{ productMetaText }}</div>
</div>
<div class="order-detail-hero__side">
<div class="order-detail-hero__tags">
<OrderStatusTag :status="detail.order_info.display_status" />
<span class="order-detail-chip">{{ detail.order_info.service_provider_text }}</span>
</div>
<div class="order-detail-hero__actions">
<el-button
v-if="canMarkReceived"
type="primary"
:loading="receiveSubmitting"
@click="markReceived"
>
标记鉴定中心签收
</el-button>
<el-button
v-if="detail.order_info.can_reassign_warehouse"
type="primary"
plain
:loading="warehouseOptionsLoading"
@click="openWarehouseDialog"
>
手动改派仓库
</el-button>
<el-button
v-if="canSubmitReturnLogistics || returnLogisticsBlockReason"
type="primary"
plain
:disabled="!canSubmitReturnLogistics"
@click="openReturnDialog"
>
{{ detail.return_logistics?.tracking_no ? '更新回寄运单' : '登记回寄运单' }}
</el-button>
<el-button
v-if="canMarkReturnReceived"
type="primary"
:loading="returnReceiveSubmitting"
@click="markReturnReceived"
>
标记用户签收
</el-button>
</div>
</div>
</div>
<div class="detail-grid">
<div class="detail-card">
<div class="detail-card__title">订单概览</div>
<div class="order-detail-grid">
<div class="order-detail-item">
<div class="order-detail-item__label">订单号</div>
<div class="order-detail-item__value">{{ detail.order_info.order_no }}</div>
</div>
<div class="order-detail-item">
<div class="order-detail-item__label">鉴定单号</div>
<div class="order-detail-item__value">{{ detail.order_info.appraisal_no }}</div>
</div>
<div class="order-detail-item">
<div class="order-detail-item__label">服务类型</div>
<div class="order-detail-item__value">{{ detail.order_info.service_provider_text }}</div>
</div>
<div class="order-detail-item">
<div class="order-detail-item__label">下单渠道</div>
<div class="order-detail-item__value">{{ detail.order_info.source_channel_text || "-" }}</div>
</div>
<div class="order-detail-item" v-if="detail.order_info.source_customer_id">
<div class="order-detail-item__label">大客户客户 ID</div>
<div class="order-detail-item__value">{{ detail.order_info.source_customer_id }}</div>
</div>
<div class="order-detail-item">
<div class="order-detail-item__label">当前状态</div>
<div class="order-detail-item__value"><OrderStatusTag :status="detail.order_info.display_status" /></div>
</div>
<div class="order-detail-item">
<div class="order-detail-item__label">订单金额</div>
<div class="order-detail-item__value">¥{{ detail.order_info.pay_amount }}</div>
</div>
<div class="order-detail-item">
<div class="order-detail-item__label">预计完成</div>
<div class="order-detail-item__value">{{ detail.order_info.estimated_finish_time || "-" }}</div>
</div>
</div>
</div>
<div class="detail-card">
<div class="detail-card__title">商品信息</div>
<div class="order-detail-grid">
<div class="order-detail-item">
<div class="order-detail-item__label">商品名称</div>
<div class="order-detail-item__value">{{ detail.product_info.product_name || "-" }}</div>
</div>
<div class="order-detail-item">
<div class="order-detail-item__label">品类 / 品牌</div>
<div class="order-detail-item__value">{{ detail.product_info.category_name || "-" }} / {{ detail.product_info.brand_name || "-" }}</div>
</div>
<div class="order-detail-item">
<div class="order-detail-item__label">颜色 / 规格</div>
<div class="order-detail-item__value">{{ detail.product_info.color || "-" }} / {{ detail.product_info.size_spec || "-" }}</div>
</div>
</div>
</div>
<div class="detail-card">
<div class="detail-card__title">补充信息</div>
<div class="order-detail-grid">
<div class="order-detail-item">
<div class="order-detail-item__label">购买渠道</div>
<div class="order-detail-item__value">{{ detail.extra_info.purchase_channel || "-" }}</div>
</div>
<div class="order-detail-item">
<div class="order-detail-item__label">购买价格</div>
<div class="order-detail-item__value">¥{{ detail.extra_info.purchase_price }}</div>
</div>
<div class="order-detail-item order-detail-item--full">
<div class="order-detail-item__label">使用情况</div>
<div class="order-detail-item__value">{{ usageStatusText }}</div>
</div>
<div class="order-detail-item order-detail-item--full">
<div class="order-detail-item__label">补充说明</div>
<div class="order-detail-item__value">{{ detail.extra_info.condition_desc || detail.extra_info.remark || "-" }}</div>
</div>
</div>
</div>
<div class="detail-card" v-if="detail.shipping_target">
<div class="detail-card__title">收货仓库</div>
<div class="order-detail-grid">
<div class="order-detail-item">
<div class="order-detail-item__label">仓库名称 / 编码</div>
<div class="order-detail-item__value">{{ detail.shipping_target.warehouse_name }} / {{ detail.shipping_target.warehouse_code }}</div>
</div>
<div class="order-detail-item">
<div class="order-detail-item__label">收件人 / 联系电话</div>
<div class="order-detail-item__value">{{ detail.shipping_target.receiver_name }} / {{ detail.shipping_target.receiver_mobile }}</div>
</div>
<div class="order-detail-item order-detail-item--full">
<div class="order-detail-item__label">收件地址</div>
<div class="order-detail-item__value">{{ detail.shipping_target.full_address }}</div>
</div>
<div class="order-detail-item order-detail-item--full">
<div class="order-detail-item__label">服务时间</div>
<div class="order-detail-item__value">{{ detail.shipping_target.service_time }}</div>
</div>
</div>
</div>
<div class="detail-card">
<div class="detail-card__title">寄回地址</div>
<div v-if="detail.return_address" class="order-detail-grid">
<div class="order-detail-item">
<div class="order-detail-item__label">收件人 / 联系电话</div>
<div class="order-detail-item__value">{{ detail.return_address.consignee }} / {{ detail.return_address.mobile }}</div>
</div>
<div class="order-detail-item order-detail-item--full">
<div class="order-detail-item__label">寄回地址</div>
<div class="order-detail-item__value">{{ detail.return_address.full_address }}</div>
</div>
</div>
<el-empty v-else description="用户暂未确认寄回地址" :image-size="64" />
</div>
<div class="detail-card" v-if="detail.logistics_info">
<div class="detail-card__title">物流信息</div>
<div class="order-detail-grid">
<div class="order-detail-item">
<div class="order-detail-item__label">快递公司 / 运单号</div>
<div class="order-detail-item__value">{{ detail.logistics_info.express_company || "-" }} / {{ detail.logistics_info.tracking_no || "-" }}</div>
</div>
<div class="order-detail-item">
<div class="order-detail-item__label">物流状态</div>
<div class="order-detail-item__value">{{ logisticsActionText }}</div>
</div>
<div class="order-detail-item order-detail-item--full">
<div class="order-detail-item__label">最新节点</div>
<div class="order-detail-item__value">{{ detail.logistics_info.latest_desc || "-" }}</div>
</div>
<div class="order-detail-item order-detail-item--full" v-if="detail.logistics_info.latest_time">
<div class="order-detail-item__label">最新更新时间</div>
<div class="order-detail-item__value">{{ detail.logistics_info.latest_time }}</div>
</div>
</div>
<div v-if="canMarkReceived" class="detail-card__desc" style="margin-top: 16px;">
<el-alert title="待签收操作" description="物流信息已提交,确认鉴定中心实际收货后再执行签收。" type="warning" :closable="false" show-icon />
</div>
</div>
<div class="detail-card" v-if="detail.return_logistics">
<div class="detail-card__title">回寄物流</div>
<div class="order-detail-grid">
<div class="order-detail-item">
<div class="order-detail-item__label">快递公司 / 运单号</div>
<div class="order-detail-item__value">{{ detail.return_logistics.express_company || "-" }} / {{ detail.return_logistics.tracking_no || "-" }}</div>
</div>
<div class="order-detail-item">
<div class="order-detail-item__label">物流状态</div>
<div class="order-detail-item__value">{{ detail.return_logistics.tracking_status_text }}</div>
</div>
<div class="order-detail-item order-detail-item--full">
<div class="order-detail-item__label">最新节点</div>
<div class="order-detail-item__value">{{ detail.return_logistics.latest_desc || "-" }}</div>
</div>
<div class="order-detail-item order-detail-item--full" v-if="detail.return_logistics.latest_time">
<div class="order-detail-item__label">最新更新时间</div>
<div class="order-detail-item__value">{{ detail.return_logistics.latest_time }}</div>
</div>
</div>
</div>
<div class="detail-card" v-if="detail.report_summary">
<div class="detail-card__title">报告信息</div>
<el-alert
v-if="returnLogisticsBlockReason"
type="warning"
:closable="false"
show-icon
:title="returnLogisticsBlockReason"
description="请先在报告中心发布订单报告,发布后再登记回寄运单。"
style="margin-top: 12px;"
/>
<div class="detail-card__desc">
<div class="detail-label">报告编号</div>
<div class="detail-value">{{ detail.report_summary.report_no }}</div>
</div>
<div class="detail-card__desc">
<div class="detail-label">报告标题</div>
<div class="detail-value">{{ detail.report_summary.report_title }}</div>
</div>
<div class="detail-card__desc">
<div class="detail-label">发布时间</div>
<div class="detail-value">{{ detail.report_summary.publish_time }}</div>
</div>
</div>
<div class="detail-card" style="grid-column: 1 / -1">
<div class="detail-card__title">时间轴</div>
<div class="timeline-list" style="margin-top: 14px">
<div v-for="item in detail.timeline" :key="`${item.node_text}-${item.occurred_at}`" class="timeline-node">
<div class="timeline-node__title">{{ item.node_text }}</div>
<div class="timeline-node__time">{{ item.occurred_at }}</div>
<div class="timeline-node__desc">{{ item.node_desc }}</div>
</div>
</div>
</div>
<div class="detail-card" v-if="detail.logistics_info" style="grid-column: 1 / -1">
<div class="detail-card__title">物流轨迹</div>
<div class="timeline-list" style="margin-top: 14px">
<div v-for="item in detail.logistics_info.nodes" :key="`${item.node_time}-${item.node_desc}`" class="timeline-node">
<div class="timeline-node__title">{{ item.node_desc }}</div>
<div class="timeline-node__time">{{ item.node_time }}</div>
<div class="timeline-node__desc">{{ item.node_location || "-" }}</div>
</div>
</div>
</div>
<div class="detail-card" v-if="detail.supplement_task" style="grid-column: 1 / -1">
<div class="detail-card__title">补图任务</div>
<div class="detail-card__desc">
<div class="detail-label">补图原因</div>
<div class="detail-value">{{ detail.supplement_task.reason }}</div>
</div>
<div class="detail-card__desc">
<div class="detail-label">截止时间</div>
<div class="detail-value">{{ detail.supplement_task.deadline }}</div>
</div>
<div class="timeline-list" style="margin-top: 14px">
<div v-for="item in detail.supplement_task.items" :key="item.item_name" class="timeline-node">
<div class="timeline-node__title">{{ item.item_name }}</div>
<div class="timeline-node__desc">{{ item.guide_text }}</div>
</div>
</div>
</div>
</div>
</div>
</el-drawer>
<el-dialog v-model="warehouseDialogVisible" title="改派收货仓库" width="720px">
<div v-loading="warehouseOptionsLoading" style="display: grid; gap: 14px;">
<div
v-for="item in warehouseOptions"
:key="item.id"
:style="{
border: selectedWarehouseId === item.id ? '1px solid #c8a45d' : '1px solid var(--admin-border)',
borderRadius: '14px',
padding: '16px 18px',
cursor: 'pointer',
background: selectedWarehouseId === item.id ? 'rgba(200, 164, 93, 0.08)' : '#fff',
}"
@click="selectedWarehouseId = item.id"
>
<div style="display:flex; justify-content:space-between; gap: 16px; align-items:center;">
<div style="font-weight:700;">{{ item.warehouse_name }}</div>
<div style="color: var(--admin-text-subtle);">{{ item.is_default ? '默认仓库' : '可选仓库' }}</div>
</div>
<div style="margin-top: 8px; color: var(--admin-text-subtle);">{{ item.service_provider_text }} / {{ item.warehouse_code }}</div>
<div style="margin-top: 8px;">{{ item.receiver_name }} / {{ item.receiver_mobile }}</div>
<div style="margin-top: 8px;">{{ item.full_address }}</div>
<div style="margin-top: 8px; color: var(--admin-text-subtle);">{{ item.service_time }}</div>
</div>
</div>
<template #footer>
<el-button @click="warehouseDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="warehouseSubmitting" @click="submitWarehouseReassign">确认改派</el-button>
</template>
</el-dialog>
<el-dialog v-model="returnDialogVisible" title="登记回寄运单" width="520px">
<el-form label-position="top">
<el-form-item label="回寄快递公司">
<el-input v-model="returnExpressCompany" placeholder="例如:顺丰速运" />
</el-form-item>
<el-form-item label="回寄运单号">
<el-input v-model="returnTrackingNo" placeholder="请输入回寄运单号" />
</el-form-item>
<el-alert
v-if="detail?.return_address"
type="info"
:closable="false"
show-icon
title="当前寄回地址"
:description="`${detail.return_address.consignee} / ${detail.return_address.mobile} / ${detail.return_address.full_address}`"
/>
<el-alert
v-else
type="warning"
:closable="false"
show-icon
title="用户尚未确认寄回地址"
description="请先提醒用户在订单详情中确认寄回地址,再登记回寄运单。"
/>
</el-form>
<template #footer>
<el-button @click="returnDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="returnSubmitting" :disabled="!canSubmitReturnLogistics" @click="submitReturnLogistics">确认登记</el-button>
</template>
</el-dialog>
</template>
<style scoped>
.order-detail-shell {
display: flex;
flex-direction: column;
gap: 18px;
}
.order-detail-hero {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 20px;
padding: 24px;
background:
radial-gradient(circle at top right, rgba(200, 164, 93, 0.12), transparent 30%),
linear-gradient(135deg, #fffdfa 0%, #fbf8f1 100%);
}
.order-detail-hero__main {
min-width: 0;
}
.order-detail-hero__eyebrow {
display: inline-flex;
align-items: center;
min-height: 28px;
padding: 0 12px;
border-radius: 999px;
background: rgba(200, 164, 93, 0.12);
color: #7a5a21;
font-size: 12px;
font-weight: 700;
}
.order-detail-hero__title {
margin-top: 14px;
color: var(--admin-text-main);
font-size: 28px;
font-weight: 800;
line-height: 1.2;
}
.order-detail-hero__meta {
margin-top: 10px;
color: var(--admin-text-subtle);
font-size: 14px;
line-height: 1.6;
}
.order-detail-hero__side {
min-width: 260px;
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 14px;
}
.order-detail-hero__tags {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 10px;
}
.order-detail-hero__actions {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 12px;
}
.order-detail-chip {
display: inline-flex;
align-items: center;
min-height: 30px;
padding: 0 12px;
border-radius: 999px;
background: rgba(72, 104, 133, 0.1);
color: var(--admin-progress);
font-size: 12px;
font-weight: 700;
}
.table-subtext {
margin-top: 4px;
color: var(--admin-text-subtle);
font-size: 12px;
line-height: 1.4;
}
.order-detail-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px;
margin-top: 16px;
}
.order-detail-item {
padding: 14px 16px;
border: 1px solid #efe8d9;
border-radius: 16px;
background: #fcfaf5;
}
.order-detail-item--full {
grid-column: 1 / -1;
}
.order-detail-item__label {
color: var(--admin-text-subtle);
font-size: 12px;
}
.order-detail-item__value {
margin-top: 8px;
color: var(--admin-text-main);
font-size: 16px;
font-weight: 700;
line-height: 1.5;
word-break: break-word;
}
@media (max-width: 1280px) {
.order-detail-hero {
grid-template-columns: 1fr;
display: grid;
}
.order-detail-hero__side {
min-width: 0;
align-items: flex-start;
}
.order-detail-hero__tags,
.order-detail-hero__actions {
justify-content: flex-start;
}
}
@media (max-width: 960px) {
.order-detail-grid {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -0,0 +1,847 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from "vue";
import { useRoute } from "vue-router";
import { ElMessage, ElMessageBox } from "element-plus";
import QRCode from "qrcode";
import {
adminApi,
type AdminManualInspectionPayload,
type AdminReportDetail,
type AdminReportListItem,
} from "../../api/admin";
import OrderStatusTag from "../../components/OrderStatusTag.vue";
function createInspectionPayload(): AdminManualInspectionPayload {
return {
report_header: {
report_no: "",
report_title: "安心验检查单",
report_status: "pending_publish",
service_provider: "anxinyan",
institution_name: "安心验",
publish_time: "",
},
product_info: {
product_name: "",
category_name: "",
brand_name: "",
color: "",
size_spec: "",
serial_no: "",
},
result_info: {
result_status: "authentic",
result_text: "正品",
result_desc: "",
},
appraisal_info: {
appraiser_name: "",
reviewer_name: "",
appraisal_time: "",
},
valuation_info: {
condition_grade: "",
condition_desc: "",
valuation_min: "",
valuation_max: "",
valuation_desc: "",
},
risk_notice_text: "",
};
}
const loading = ref(false);
const detailLoading = ref(false);
const drawerVisible = ref(false);
const inspectionDrawerVisible = ref(false);
const inspectionSubmitting = ref(false);
const publishingId = ref<number | null>(null);
const detailQrDataUrl = ref("");
const keyword = ref("");
const serviceProvider = ref("");
const reportStatus = ref("");
const reports = ref<AdminReportListItem[]>([]);
const detail = ref<AdminReportDetail | null>(null);
const inspectionForm = ref<AdminManualInspectionPayload>(createInspectionPayload());
const route = useRoute();
const canPublishCurrentReport = computed(() => detail.value?.report_header.report_status === "pending_publish");
const canEditCurrentInspection = computed(
() => detail.value?.report_header.report_type === "inspection" && detail.value?.report_header.report_status !== "published",
);
const inspectionDrawerTitle = computed(() => (inspectionForm.value.id ? "编辑补录检查单" : "补录检查单"));
const providerOptions = [
{ label: "全部服务", value: "" },
{ label: "实物鉴定", value: "anxinyan" },
{ label: "中检鉴定", value: "zhongjian" },
];
const statusOptions = [
{ label: "全部状态", value: "" },
{ label: "已发布", value: "published" },
{ label: "待发布", value: "pending_publish" },
{ label: "草稿中", value: "draft" },
{ label: "已更新", value: "updated" },
{ label: "已作废", value: "invalid" },
];
const inspectionStatusOptions = [
{ label: "草稿保存", value: "draft" },
{ label: "待发布", value: "pending_publish" },
{ label: "直接发布", value: "published" },
];
const resultOptions = [
{ label: "正品", value: "authentic", text: "正品" },
{ label: "存疑", value: "uncertain", text: "存疑" },
{ label: "非正品", value: "not_authentic", text: "非正品" },
];
function applyProviderPreset(force = false) {
const provider = inspectionForm.value.report_header.service_provider;
const title = provider === "zhongjian" ? "中检检查单" : "安心验检查单";
const institution = provider === "zhongjian" ? "中检合作机构" : "安心验";
if (force || !inspectionForm.value.report_header.report_title) {
inspectionForm.value.report_header.report_title = title;
}
if (force || !inspectionForm.value.report_header.institution_name) {
inspectionForm.value.report_header.institution_name = institution;
}
}
function syncResultText() {
const matched = resultOptions.find((item) => item.value === inspectionForm.value.result_info.result_status);
if (matched && !inspectionForm.value.result_info.result_text) {
inspectionForm.value.result_info.result_text = matched.text;
}
}
function previewEvidence(url: string) {
if (!url) return;
window.open(url, "_blank", "noopener,noreferrer");
}
function evidenceTypeLabel(fileType?: string) {
return fileType === "image" ? "图片" : fileType === "video" ? "视频" : fileType === "pdf" ? "PDF" : "附件";
}
const imageEvidenceList = computed(() =>
(detail.value?.evidence_attachments || []).filter((item) => item.file_type === "image"),
);
const fileEvidenceList = computed(() =>
(detail.value?.evidence_attachments || []).filter((item) => item.file_type !== "image"),
);
function openInspectionCreate() {
inspectionForm.value = createInspectionPayload();
applyProviderPreset(true);
syncResultText();
inspectionDrawerVisible.value = true;
}
function openInspectionEditFromDetail() {
if (!detail.value) return;
inspectionForm.value = {
id: detail.value.report_header.id,
report_header: {
report_no: detail.value.report_header.report_no,
report_title: detail.value.report_header.report_title,
report_status: detail.value.report_header.report_status,
service_provider: detail.value.report_header.service_provider,
institution_name: detail.value.report_header.institution_name,
publish_time: detail.value.report_header.publish_time || "",
},
product_info: {
product_name: detail.value.product_info.product_name || "",
category_name: detail.value.product_info.category_name || "",
brand_name: detail.value.product_info.brand_name || "",
color: detail.value.product_info.color || "",
size_spec: detail.value.product_info.size_spec || "",
serial_no: detail.value.product_info.serial_no || "",
},
result_info: {
result_status: detail.value.result_info.result_status || "authentic",
result_text: detail.value.result_info.result_text || "",
result_desc: detail.value.result_info.result_desc || "",
},
appraisal_info: {
appraiser_name: detail.value.appraisal_info.appraiser_name || "",
reviewer_name: detail.value.appraisal_info.reviewer_name || "",
appraisal_time: detail.value.appraisal_info.appraisal_time || "",
},
valuation_info: {
condition_grade: detail.value.valuation_info.condition_grade || "",
condition_desc: detail.value.valuation_info.condition_desc || "",
valuation_min: detail.value.valuation_info.valuation_min ?? "",
valuation_max: detail.value.valuation_info.valuation_max ?? "",
valuation_desc: detail.value.valuation_info.valuation_desc || "",
},
risk_notice_text: detail.value.risk_notice_text || "",
};
inspectionDrawerVisible.value = true;
}
async function syncQrCode(url: string) {
if (!/^https?:\/\//i.test(url)) {
detailQrDataUrl.value = "";
return;
}
try {
detailQrDataUrl.value = await QRCode.toDataURL(url, {
width: 220,
margin: 1,
});
} catch (error) {
console.error(error);
detailQrDataUrl.value = "";
}
}
async function fetchReports() {
loading.value = true;
try {
const response = await adminApi.getReports({
keyword: keyword.value,
service_provider: serviceProvider.value,
status: reportStatus.value,
});
if (response.code !== 0) {
ElMessage.error(response.message || "报告列表加载失败");
return;
}
reports.value = response.data.list;
} catch (error) {
console.error(error);
ElMessage.error("报告列表加载失败");
} finally {
loading.value = false;
}
}
async function loadDetail(id: number) {
detailLoading.value = true;
detailQrDataUrl.value = "";
try {
const response = await adminApi.getReportDetail(id);
if (response.code !== 0) {
ElMessage.error(response.message || "报告详情加载失败");
return;
}
detail.value = response.data;
await syncQrCode(response.data.verify_info.verify_qrcode_url || response.data.verify_info.report_page_url || "");
} catch (error) {
console.error(error);
ElMessage.error("报告详情加载失败");
} finally {
detailLoading.value = false;
}
}
async function openDetail(row: AdminReportListItem) {
drawerVisible.value = true;
await loadDetail(row.id);
}
function parseReportId(value: unknown) {
const raw = Array.isArray(value) ? value[0] : value;
const id = Number(raw || 0);
return Number.isInteger(id) && id > 0 ? id : 0;
}
async function openDetailFromRouteQuery() {
const reportId = parseReportId(route.query.report_id);
if (!reportId) {
return;
}
if (drawerVisible.value && detail.value?.report_header.id === reportId) {
return;
}
drawerVisible.value = true;
await loadDetail(reportId);
}
async function publishReport(row: Pick<AdminReportListItem, "id" | "report_status"> | { id: number; report_status: string }) {
if (row.report_status !== "pending_publish") {
ElMessage.warning("仅待发布报告可以执行发布");
return;
}
try {
await ElMessageBox.confirm("发布后用户端将可查看正式报告并进行验真,是否继续?", "发布报告", {
type: "warning",
confirmButtonText: "确认发布",
cancelButtonText: "取消",
});
} catch {
return;
}
publishingId.value = row.id;
try {
const response = await adminApi.publishReport(row.id);
if (response.code !== 0) {
ElMessage.error(response.message || "报告发布失败");
return;
}
ElMessage.success(response.message || "报告已发布");
await fetchReports();
if (drawerVisible.value && detail.value?.report_header.id === row.id) {
await loadDetail(row.id);
}
} catch (error) {
console.error(error);
ElMessage.error("报告发布失败");
} finally {
publishingId.value = null;
}
}
function validateInspectionForm() {
const { report_header, product_info, result_info } = inspectionForm.value;
if (!report_header.report_title.trim()) {
ElMessage.warning("请填写检查单标题");
return false;
}
if (!report_header.institution_name.trim()) {
ElMessage.warning("请填写出具机构");
return false;
}
if (!product_info.product_name.trim()) {
ElMessage.warning("请填写商品名称");
return false;
}
if (!result_info.result_text.trim()) {
ElMessage.warning("请填写鉴定结论");
return false;
}
return true;
}
async function saveInspection() {
if (!validateInspectionForm()) {
return;
}
inspectionSubmitting.value = true;
try {
const response = await adminApi.saveInspectionReport(inspectionForm.value);
if (response.code !== 0) {
ElMessage.error(response.message || "检查单保存失败");
return;
}
ElMessage.success(response.message || "检查单已保存");
inspectionDrawerVisible.value = false;
await fetchReports();
drawerVisible.value = true;
await loadDetail(response.data.id);
} catch (error) {
console.error(error);
ElMessage.error("检查单保存失败");
} finally {
inspectionSubmitting.value = false;
}
}
async function copyText(value: string, label: string) {
if (!value) {
ElMessage.warning(`${label}为空`);
return;
}
try {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(value);
} else {
const input = document.createElement("textarea");
input.value = value;
document.body.appendChild(input);
input.select();
document.execCommand("copy");
document.body.removeChild(input);
}
ElMessage.success(`${label}已复制`);
} catch (error) {
console.error(error);
ElMessage.error(`${label}复制失败`);
}
}
onMounted(() => {
applyProviderPreset(true);
syncResultText();
fetchReports();
openDetailFromRouteQuery();
});
watch(
() => route.query.report_id,
() => {
openDetailFromRouteQuery();
},
);
</script>
<template>
<el-card class="panel-card" shadow="never">
<div class="filters-row" style="justify-content: space-between;">
<div class="filters-row">
<el-input v-model="keyword" placeholder="搜索报告编号 / 鉴定单号 / 订单号 / 商品名称" clearable style="width: 340px" />
<el-select v-model="serviceProvider" placeholder="服务类型" style="width: 160px">
<el-option v-for="item in providerOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
<el-select v-model="reportStatus" placeholder="报告状态" style="width: 160px">
<el-option v-for="item in statusOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
<el-button type="primary" @click="fetchReports">查询</el-button>
</div>
<el-button type="primary" plain @click="openInspectionCreate">补录检查单</el-button>
</div>
</el-card>
<el-card class="panel-card orders-table" shadow="never">
<el-table v-loading="loading" :data="reports" stripe>
<el-table-column prop="report_no" label="报告编号" min-width="180" />
<el-table-column prop="appraisal_no" label="鉴定单号" min-width="180" />
<el-table-column prop="report_type_text" label="类型" min-width="120" />
<el-table-column prop="report_title" label="报告标题" min-width="180" />
<el-table-column prop="product_name" label="商品名称" min-width="220" />
<el-table-column prop="service_provider_text" label="服务类型" min-width="120" />
<el-table-column label="报告状态" min-width="120">
<template #default="{ row }">
<OrderStatusTag :status="row.report_status_text" />
</template>
</el-table-column>
<el-table-column prop="institution_name" label="出具机构" min-width="160" />
<el-table-column prop="publish_time" label="发布时间" min-width="170" />
<el-table-column label="操作" fixed="right" width="220">
<template #default="{ row }">
<el-button link type="primary" @click="openDetail(row)">查看详情</el-button>
<el-button
v-if="row.report_type === 'inspection' && row.report_status !== 'published'"
link
type="success"
@click="openDetail(row).then(() => openInspectionEditFromDetail())"
>
编辑检查单
</el-button>
<el-button
v-if="row.report_status === 'pending_publish'"
link
type="warning"
:loading="publishingId === row.id"
@click="publishReport(row)"
>
发布报告
</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
<el-drawer v-model="drawerVisible" size="62%" title="报告详情">
<div v-loading="detailLoading" v-if="detail" class="detail-grid">
<div style="grid-column: 1 / -1; display: flex; justify-content: flex-end; gap: 12px; margin-bottom: 8px">
<el-button v-if="canEditCurrentInspection" type="success" plain @click="openInspectionEditFromDetail">
编辑检查单
</el-button>
<el-button
v-if="canPublishCurrentReport"
type="primary"
:loading="publishingId === detail.report_header.id"
@click="publishReport({ id: detail.report_header.id, report_status: detail.report_header.report_status })"
>
发布报告
</el-button>
</div>
<div class="detail-card">
<div class="detail-card__title">报告概览</div>
<div class="detail-card__desc">
<div class="detail-label">报告编号</div>
<div class="detail-value">{{ detail.report_header.report_no }}</div>
</div>
<div class="detail-card__desc">
<div class="detail-label">报告类型</div>
<div class="detail-value">{{ detail.report_header.report_type_text }}</div>
</div>
<div class="detail-card__desc">
<div class="detail-label">报告标题</div>
<div class="detail-value">{{ detail.report_header.report_title }}</div>
</div>
<div class="detail-card__desc">
<div class="detail-label">报告状态</div>
<div class="detail-value">
<OrderStatusTag :status="detail.report_header.report_status_text" />
</div>
</div>
<div class="detail-card__desc">
<div class="detail-label">出具机构</div>
<div class="detail-value">{{ detail.report_header.institution_name }}</div>
</div>
</div>
<div class="detail-card">
<div class="detail-card__title">商品信息</div>
<div class="detail-card__desc">
<div class="detail-label">商品名称</div>
<div class="detail-value">{{ detail.product_info.product_name || "-" }}</div>
</div>
<div class="detail-card__desc">
<div class="detail-label">品类 / 品牌</div>
<div class="detail-value">{{ detail.product_info.category_name || "-" }} / {{ detail.product_info.brand_name || "-" }}</div>
</div>
<div class="detail-card__desc">
<div class="detail-label">颜色 / 规格</div>
<div class="detail-value">{{ detail.product_info.color || "-" }} / {{ detail.product_info.size_spec || "-" }}</div>
</div>
<div class="detail-card__desc">
<div class="detail-label">序列号</div>
<div class="detail-value">{{ detail.product_info.serial_no || "-" }}</div>
</div>
</div>
<div class="detail-card">
<div class="detail-card__title">鉴定结果</div>
<div class="detail-card__desc">
<div class="detail-label">结论</div>
<div class="detail-value">{{ detail.result_info.result_text || "-" }}</div>
</div>
<div class="detail-card__desc">
<div class="detail-label">说明</div>
<div class="detail-value">{{ detail.result_info.result_desc || "-" }}</div>
</div>
</div>
<div class="detail-card">
<div class="detail-card__title">鉴定信息</div>
<div class="detail-card__desc">
<div class="detail-label">服务类型</div>
<div class="detail-value">{{ detail.report_header.service_provider_text }}</div>
</div>
<div class="detail-card__desc">
<div class="detail-label">鉴定师</div>
<div class="detail-value">{{ detail.appraisal_info.appraiser_name || "-" }}</div>
</div>
<div class="detail-card__desc">
<div class="detail-label">鉴定时间</div>
<div class="detail-value">{{ detail.appraisal_info.appraisal_time || "-" }}</div>
</div>
</div>
<div class="detail-card">
<div class="detail-card__title">评级与估值</div>
<div class="detail-card__desc">
<div class="detail-label">成色评级</div>
<div class="detail-value">{{ detail.valuation_info.condition_grade || "-" }}</div>
</div>
<div class="detail-card__desc">
<div class="detail-label">估值区间</div>
<div class="detail-value">¥{{ detail.valuation_info.valuation_min || 0 }} - ¥{{ detail.valuation_info.valuation_max || 0 }}</div>
</div>
<div class="detail-card__desc">
<div class="detail-label">估值说明</div>
<div class="detail-value">{{ detail.valuation_info.valuation_desc || "-" }}</div>
</div>
</div>
<div class="detail-card" style="grid-column: 1 / -1">
<div class="detail-card__title">证据附件</div>
<div v-if="detail.evidence_attachments.length" class="report-evidence-stack">
<div v-if="imageEvidenceList.length" class="report-evidence-section">
<div class="report-evidence-section__title">图片证据</div>
<div class="report-evidence-gallery">
<div
v-for="attachment in imageEvidenceList"
:key="attachment.file_id"
class="report-evidence-gallery__item"
@click="previewEvidence(attachment.file_url)"
>
<img :src="attachment.thumbnail_url || attachment.file_url" :alt="attachment.name || '证据图片'" />
<div class="report-evidence-gallery__caption">{{ attachment.name || "未命名图片" }}</div>
</div>
</div>
</div>
<div v-if="fileEvidenceList.length" class="report-evidence-section">
<div class="report-evidence-section__title">视频 / 文档证据</div>
<div class="report-evidence-list">
<div v-for="attachment in fileEvidenceList" :key="attachment.file_id" class="report-evidence-card">
<div class="report-evidence-card__preview" @click="previewEvidence(attachment.file_url)">
<div class="report-evidence-card__filetype">{{ evidenceTypeLabel(attachment.file_type) }}</div>
</div>
<div class="report-evidence-card__body">
<div class="detail-value" style="margin-top: 0; word-break: break-word;">{{ attachment.name || attachment.file_url }}</div>
<div class="detail-label" style="margin-top: 6px;">{{ evidenceTypeLabel(attachment.file_type) }}</div>
<el-button size="small" style="margin-top: 10px" @click="previewEvidence(attachment.file_url)">查看附件</el-button>
</div>
</div>
</div>
</div>
</div>
<div v-else class="detail-card__desc">
<div class="detail-value">当前报告未附带证据附件</div>
</div>
</div>
<div class="detail-card" style="grid-column: 1 / -1">
<div class="detail-card__title">扫码与公开链接</div>
<div style="display: grid; grid-template-columns: 220px 1fr; gap: 24px; align-items: start;">
<div
style="width: 220px; height: 220px; border-radius: 16px; border: 1px dashed var(--admin-border); display: flex; align-items: center; justify-content: center; overflow: hidden; background: #fff;"
>
<el-image v-if="detailQrDataUrl" :src="detailQrDataUrl" fit="contain" style="width: 200px; height: 200px" />
<div v-else style="padding: 16px; text-align: center; color: var(--admin-text-subtle); line-height: 1.7;">
请先在系统配置中填写 H5 页面根地址再生成可扫码的公开链接
</div>
</div>
<div style="display: grid; gap: 14px;">
<div class="detail-card__desc" style="margin: 0;">
<div class="detail-label">扫码打开报告页</div>
<div class="detail-value" style="word-break: break-all;">{{ detail.verify_info.verify_qrcode_url || "-" }}</div>
<el-button size="small" style="margin-top: 8px" @click="copyText(detail.verify_info.verify_qrcode_url, '报告链接')">复制报告链接</el-button>
</div>
<div class="detail-card__desc" style="margin: 0;">
<div class="detail-label">H5 验真页</div>
<div class="detail-value" style="word-break: break-all;">{{ detail.verify_info.verify_url || "-" }}</div>
<el-button size="small" style="margin-top: 8px" @click="copyText(detail.verify_info.verify_url, '验真链接')">复制验真链接</el-button>
</div>
<div class="detail-card__desc" style="margin: 0;">
<div class="detail-label">验真状态 / 次数</div>
<div class="detail-value">{{ detail.verify_info.verify_status }} / {{ detail.verify_info.verify_count }}</div>
</div>
</div>
</div>
</div>
<div class="detail-card" style="grid-column: 1 / -1">
<div class="detail-card__title">风险说明</div>
<div class="detail-card__desc">
<div class="detail-value">{{ detail.risk_notice_text || "-" }}</div>
</div>
</div>
</div>
</el-drawer>
<el-drawer v-model="inspectionDrawerVisible" size="56%" :title="inspectionDrawerTitle">
<div style="display: grid; gap: 24px;">
<el-card shadow="never">
<template #header>基础信息</template>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="检查单编号">
<el-input v-model="inspectionForm.report_header.report_no" placeholder="可留空,系统自动生成" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="检查单标题">
<el-input v-model="inspectionForm.report_header.report_title" placeholder="请输入检查单标题" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="服务类型">
<el-select v-model="inspectionForm.report_header.service_provider" style="width: 100%" @change="applyProviderPreset()">
<el-option label="实物鉴定" value="anxinyan" />
<el-option label="中检鉴定" value="zhongjian" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="保存状态">
<el-select v-model="inspectionForm.report_header.report_status" style="width: 100%">
<el-option v-for="item in inspectionStatusOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="出具机构">
<el-input v-model="inspectionForm.report_header.institution_name" placeholder="请输入出具机构" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="发布时间">
<el-date-picker
v-model="inspectionForm.report_header.publish_time"
type="datetime"
value-format="YYYY-MM-DD HH:mm:ss"
placeholder="直接发布时可指定发布时间"
style="width: 100%"
/>
</el-form-item>
</el-col>
</el-row>
</el-card>
<el-card shadow="never">
<template #header>商品信息</template>
<el-row :gutter="16">
<el-col :span="12"><el-form-item label="商品名称"><el-input v-model="inspectionForm.product_info.product_name" /></el-form-item></el-col>
<el-col :span="12"><el-form-item label="品类"><el-input v-model="inspectionForm.product_info.category_name" /></el-form-item></el-col>
<el-col :span="12"><el-form-item label="品牌"><el-input v-model="inspectionForm.product_info.brand_name" /></el-form-item></el-col>
<el-col :span="12"><el-form-item label="颜色"><el-input v-model="inspectionForm.product_info.color" /></el-form-item></el-col>
<el-col :span="12"><el-form-item label="规格 / 尺寸"><el-input v-model="inspectionForm.product_info.size_spec" /></el-form-item></el-col>
<el-col :span="24"><el-form-item label="序列号 / 编码"><el-input v-model="inspectionForm.product_info.serial_no" /></el-form-item></el-col>
</el-row>
</el-card>
<el-card shadow="never">
<template #header>鉴定结果</template>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="结果类型">
<el-select v-model="inspectionForm.result_info.result_status" style="width: 100%" @change="syncResultText">
<el-option v-for="item in resultOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="结果文案">
<el-input v-model="inspectionForm.result_info.result_text" placeholder="例如:正品 / 存疑 / 非正品" />
</el-form-item>
</el-col>
<el-col :span="24">
<el-form-item label="结果说明">
<el-input v-model="inspectionForm.result_info.result_desc" type="textarea" :rows="4" placeholder="请输入检查结论说明" />
</el-form-item>
</el-col>
</el-row>
</el-card>
<el-card shadow="never">
<template #header>鉴定与估值信息</template>
<el-row :gutter="16">
<el-col :span="12"><el-form-item label="鉴定师"><el-input v-model="inspectionForm.appraisal_info.appraiser_name" /></el-form-item></el-col>
<el-col :span="12">
<el-form-item label="鉴定时间">
<el-date-picker
v-model="inspectionForm.appraisal_info.appraisal_time"
type="datetime"
value-format="YYYY-MM-DD HH:mm:ss"
style="width: 100%"
/>
</el-form-item>
</el-col>
<el-col :span="12"><el-form-item label="成色评级"><el-input v-model="inspectionForm.valuation_info.condition_grade" placeholder="例如 A / B+" /></el-form-item></el-col>
<el-col :span="12"><el-form-item label="最低估值"><el-input v-model="inspectionForm.valuation_info.valuation_min" type="number" /></el-form-item></el-col>
<el-col :span="12"><el-form-item label="最高估值"><el-input v-model="inspectionForm.valuation_info.valuation_max" type="number" /></el-form-item></el-col>
<el-col :span="24"><el-form-item label="成色说明"><el-input v-model="inspectionForm.valuation_info.condition_desc" type="textarea" :rows="3" /></el-form-item></el-col>
<el-col :span="24"><el-form-item label="估值说明"><el-input v-model="inspectionForm.valuation_info.valuation_desc" type="textarea" :rows="3" /></el-form-item></el-col>
</el-row>
</el-card>
<el-card shadow="never">
<template #header>风险说明</template>
<el-form-item label="页面说明文案">
<el-input v-model="inspectionForm.risk_notice_text" type="textarea" :rows="4" placeholder="请输入风险提示与适用说明" />
</el-form-item>
</el-card>
<div style="display: flex; justify-content: flex-end; gap: 12px;">
<el-button @click="inspectionDrawerVisible = false">取消</el-button>
<el-button type="primary" :loading="inspectionSubmitting" @click="saveInspection">保存检查单</el-button>
</div>
</div>
</el-drawer>
</template>
<style scoped>
.report-evidence-stack {
display: flex;
flex-direction: column;
gap: 20px;
margin-top: 14px;
}
.report-evidence-section__title {
color: var(--admin-text-main);
font-size: 14px;
font-weight: 700;
}
.report-evidence-gallery {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 14px;
margin-top: 12px;
}
.report-evidence-gallery__item {
border-radius: 16px;
overflow: hidden;
border: 1px solid #efe8d9;
background: #fcfaf5;
cursor: pointer;
}
.report-evidence-gallery__item img {
width: 100%;
height: 180px;
object-fit: cover;
display: block;
}
.report-evidence-gallery__caption {
padding: 10px 12px;
color: var(--admin-text-main);
font-size: 13px;
font-weight: 600;
line-height: 1.5;
word-break: break-word;
}
.report-evidence-list {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
gap: 14px;
margin-top: 12px;
}
.report-evidence-card {
display: grid;
grid-template-columns: 96px minmax(0, 1fr);
gap: 14px;
padding: 14px;
border-radius: 16px;
background: #fcfaf5;
border: 1px solid #efe8d9;
}
.report-evidence-card__preview {
width: 96px;
height: 96px;
border-radius: 14px;
border: 1px solid #efe8d9;
background: #ffffff;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
cursor: pointer;
}
.report-evidence-card__preview img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.report-evidence-card__filetype {
color: var(--admin-progress);
font-size: 13px;
font-weight: 700;
}
.report-evidence-card__body {
min-width: 0;
}
</style>

View File

@@ -0,0 +1,294 @@
<script setup lang="ts">
import { onMounted, ref } from "vue";
import { ElMessage } from "element-plus";
import type { UploadRequestOptions } from "element-plus";
import { adminApi, type AdminSystemConfigGroupItem } from "../../api/admin";
const loading = ref(false);
const savingGroupCode = ref("");
const uploadingKey = ref("");
const groups = ref<AdminSystemConfigGroupItem[]>([]);
const groupSnapshots = ref<Record<string, Record<string, string>>>({});
const groupOrder = ["file_storage", "mini_program", "h5", "payment", "sms"];
function cloneSnapshot(groupsList: AdminSystemConfigGroupItem[]) {
return Object.fromEntries(
groupsList.map((group) => [
group.group_code,
Object.fromEntries(group.items.map((item) => [item.config_key, item.value])),
]),
);
}
function sortGroups(groupsList: AdminSystemConfigGroupItem[]) {
return [...groupsList].sort((a, b) => {
const aIndex = groupOrder.indexOf(a.group_code);
const bIndex = groupOrder.indexOf(b.group_code);
const safeA = aIndex === -1 ? groupOrder.length : aIndex;
const safeB = bIndex === -1 ? groupOrder.length : bIndex;
return safeA - safeB;
});
}
async function fetchConfigs() {
loading.value = true;
try {
const response = await adminApi.getSystemConfigs();
groups.value = sortGroups(response.data.groups);
groupSnapshots.value = cloneSnapshot(groups.value);
} catch (error) {
console.error(error);
ElMessage.error("系统配置加载失败");
} finally {
loading.value = false;
}
}
function isGroupDirty(group: AdminSystemConfigGroupItem) {
const snapshot = groupSnapshots.value[group.group_code] || {};
return group.items.some((item) => (snapshot[item.config_key] || "") !== item.value);
}
function markGroupSnapshot(group: AdminSystemConfigGroupItem) {
groupSnapshots.value[group.group_code] = Object.fromEntries(
group.items.map((item) => [item.config_key, item.value]),
);
}
function markFieldSnapshot(groupCode: string, configKey: string, value: string) {
groupSnapshots.value[groupCode] = {
...(groupSnapshots.value[groupCode] || {}),
[configKey]: value,
};
}
async function saveGroup(group: AdminSystemConfigGroupItem) {
savingGroupCode.value = group.group_code;
try {
const items = group.items.map((item) => ({
config_group: group.group_code,
config_key: item.config_key,
config_value: item.value,
}));
await adminApi.saveSystemConfigs(items);
markGroupSnapshot(group);
ElMessage.success(`${group.group_name}已保存`);
} catch (error) {
console.error(error);
ElMessage.error(`${group.group_name}保存失败`);
} finally {
savingGroupCode.value = "";
}
}
function uploadKey(groupCode: string, configKey: string) {
return `${groupCode}.${configKey}`;
}
function isFieldVisible(group: AdminSystemConfigGroupItem, item: AdminSystemConfigGroupItem["items"][number]) {
if (!item.visible_when) {
return true;
}
const dependency = group.items.find((field) => field.config_key === item.visible_when?.config_key);
return (dependency?.value || "") === item.visible_when.equals;
}
function uploadedFileName(value: string) {
if (!value) return "";
const normalized = value.replace(/\\/g, "/");
return normalized.split("/").pop() || value;
}
async function handleUpload(options: UploadRequestOptions, groupCode: string, configKey: string) {
const file = options.file as File;
const key = uploadKey(groupCode, configKey);
uploadingKey.value = key;
try {
const response = await adminApi.uploadSystemConfigFile(groupCode, configKey, file);
const group = groups.value.find((item) => item.group_code === groupCode);
const field = group?.items.find((item) => item.config_key === configKey);
if (field) {
field.value = response.data.config_value;
markFieldSnapshot(groupCode, configKey, response.data.config_value);
}
ElMessage.success(`${response.data.file_name} 上传成功`);
options.onSuccess?.(response.data);
} catch (error) {
console.error(error);
ElMessage.error("文件上传失败");
} finally {
uploadingKey.value = "";
}
}
onMounted(fetchConfigs);
</script>
<template>
<div v-loading="loading">
<el-card class="panel-card" shadow="never">
<div class="filters-row" style="justify-content: space-between;">
<div>
<div style="font-size: 18px; font-weight: 700;">系统配置</div>
<div style="color: var(--admin-text-subtle); margin-top: 6px;">
按模块独立维护配置每个模块单独保存避免一次提交修改整页全部参数
</div>
</div>
</div>
</el-card>
<el-card
v-for="group in groups"
:key="group.group_code"
class="panel-card"
shadow="never"
>
<div class="config-group__header">
<div>
<div style="font-size: 16px; font-weight: 700;">{{ group.group_name }}</div>
<div style="color: var(--admin-text-subtle); margin-top: 6px;">
{{ group.group_desc }}
</div>
</div>
<div class="config-group__actions">
<span v-if="isGroupDirty(group)" class="config-group__dirty">本模块有未保存修改</span>
<el-button
type="primary"
:disabled="!isGroupDirty(group)"
:loading="savingGroupCode === group.group_code"
@click="saveGroup(group)"
>
保存本模块
</el-button>
</div>
</div>
<el-form label-position="top">
<el-row :gutter="16">
<el-col
v-for="item in group.items"
:key="`${group.group_code}-${item.config_key}`"
v-show="isFieldVisible(group, item)"
:span="item.field_type === 'textarea' ? 24 : 12"
>
<el-form-item :label="item.title">
<template v-if="item.field_type === 'file'">
<div class="config-upload">
<div class="config-upload__meta">
<div class="config-upload__label">
{{ item.value ? `已上传:${uploadedFileName(item.value)}` : item.placeholder }}
</div>
<div class="config-upload__path" v-if="item.value">
{{ item.value }}
</div>
</div>
<el-upload
:show-file-list="false"
accept=".pem"
:http-request="(options: UploadRequestOptions) => handleUpload(options, group.group_code, item.config_key)"
>
<el-button
type="primary"
plain
:loading="uploadingKey === uploadKey(group.group_code, item.config_key)"
>
{{ item.value ? "重新上传" : "上传 PEM 文件" }}
</el-button>
</el-upload>
</div>
</template>
<el-select
v-else-if="item.field_type === 'select'"
v-model="item.value"
style="width: 100%"
:placeholder="item.placeholder"
>
<el-option
v-for="option in item.options || []"
:key="`${group.group_code}-${item.config_key}-${option.value}`"
:label="option.label"
:value="option.value"
/>
</el-select>
<el-input
v-else-if="item.field_type !== 'textarea'"
v-model="item.value"
:type="item.field_type === 'password' ? 'password' : 'text'"
show-password
:placeholder="item.placeholder"
/>
<el-input
v-else
v-model="item.value"
type="textarea"
:rows="5"
:placeholder="item.placeholder"
/>
<div style="margin-top: 6px; color: var(--admin-text-subtle); font-size: 12px;">
{{ item.remark }}
</div>
</el-form-item>
</el-col>
</el-row>
</el-form>
</el-card>
</div>
</template>
<style scoped>
.config-group__header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 20px;
margin-bottom: 18px;
}
.config-group__actions {
display: flex;
align-items: center;
gap: 12px;
flex-shrink: 0;
}
.config-group__dirty {
color: var(--admin-warning, #b7791f);
font-size: 13px;
line-height: 1.6;
}
.config-upload {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
padding: 14px 16px;
border: 1px solid var(--admin-border, #e5ddd1);
border-radius: 14px;
background: linear-gradient(180deg, rgba(255, 251, 244, 0.92) 0%, rgba(247, 242, 233, 0.72) 100%);
}
.config-upload__meta {
min-width: 0;
flex: 1;
}
.config-upload__label {
color: var(--admin-text, #2f2a22);
font-size: 14px;
font-weight: 600;
line-height: 1.6;
}
.config-upload__path {
margin-top: 6px;
color: var(--admin-text-subtle, #8f866f);
font-size: 12px;
line-height: 1.6;
word-break: break-all;
}
</style>

View File

@@ -0,0 +1,316 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from "vue";
import { ElMessage } from "element-plus";
import { adminApi, type AdminTicketDetail, type AdminTicketItem, type AdminTicketOverviewCard } from "../../api/admin";
import OrderStatusTag from "../../components/OrderStatusTag.vue";
const loading = ref(false);
const detailLoading = ref(false);
const drawerVisible = ref(false);
const replySubmitting = ref(false);
const ticketSubmitting = ref(false);
const uploadInput = ref<HTMLInputElement | null>(null);
const attachmentUploading = ref(false);
const cards = ref<AdminTicketOverviewCard[]>([]);
const tickets = ref<AdminTicketItem[]>([]);
const detail = ref<AdminTicketDetail | null>(null);
const ticketTypeOptions = ref<Array<{ code: string; title: string }>>([]);
const ticketForm = reactive({
status: "pending",
priority: "normal",
});
const replyContent = ref("");
const replyAttachments = ref<Array<{
file_id: string;
file_url: string;
thumbnail_url: string;
name?: string;
}>>([]);
const keyword = ref("");
const ticketType = ref("");
const status = ref("");
const typeOptions = computed(() => [
{ label: "全部类型", value: "" },
...ticketTypeOptions.value.map((item) => ({
label: item.title,
value: item.code,
})),
]);
const statusOptions = [
{ label: "全部状态", value: "" },
{ label: "待处理", value: "pending" },
{ label: "处理中", value: "processing" },
{ label: "已解决", value: "resolved" },
{ label: "已关闭", value: "closed" },
];
async function fetchAll() {
loading.value = true;
try {
const [overviewRes, ticketsRes] = await Promise.all([
adminApi.getTicketOverview(),
adminApi.getTickets({
keyword: keyword.value,
ticket_type: ticketType.value,
status: status.value,
}),
]);
const metaRes = await adminApi.getContentMeta();
cards.value = overviewRes.data.cards;
tickets.value = ticketsRes.data.list;
ticketTypeOptions.value = metaRes.data.meta_config.ticket_types.map((item) => ({
code: item.code,
title: item.title,
}));
} catch (error) {
console.error(error);
ElMessage.error("工单数据加载失败");
} finally {
loading.value = false;
}
}
async function openDetail(row: AdminTicketItem) {
drawerVisible.value = true;
detailLoading.value = true;
try {
const response = await adminApi.getTicketDetail(row.id);
detail.value = response.data;
ticketForm.status = response.data.ticket_info.status;
ticketForm.priority = response.data.ticket_info.priority;
replyContent.value = "";
replyAttachments.value = [];
} catch (error) {
console.error(error);
ElMessage.error("工单详情加载失败");
} finally {
detailLoading.value = false;
}
}
async function submitTicket() {
if (!detail.value) return;
ticketSubmitting.value = true;
try {
await adminApi.saveTicket({
id: detail.value.ticket_info.id,
status: ticketForm.status,
priority: ticketForm.priority,
});
ElMessage.success("工单状态更新成功");
await openDetail({ ...detail.value.ticket_info, title: detail.value.ticket_info.title } as AdminTicketItem);
await fetchAll();
} catch (error) {
console.error(error);
ElMessage.error("工单状态更新失败");
} finally {
ticketSubmitting.value = false;
}
}
async function submitReply() {
if (!detail.value || (!replyContent.value.trim() && !replyAttachments.value.length)) {
ElMessage.warning("请输入回复内容或上传附件");
return;
}
replySubmitting.value = true;
try {
await adminApi.replyTicket(detail.value.ticket_info.id, replyContent.value.trim(), replyAttachments.value);
ElMessage.success("回复成功");
replyContent.value = "";
replyAttachments.value = [];
await openDetail({ ...detail.value.ticket_info, title: detail.value.ticket_info.title } as AdminTicketItem);
await fetchAll();
} catch (error) {
console.error(error);
ElMessage.error("工单回复失败");
} finally {
replySubmitting.value = false;
}
}
function previewAttachment(url: string) {
window.open(url, "_blank");
}
function triggerUpload() {
uploadInput.value?.click();
}
async function handleFileSelect(event: Event) {
const target = event.target as HTMLInputElement;
const files = Array.from(target.files || []);
if (!files.length) {
return;
}
attachmentUploading.value = true;
try {
for (const file of files) {
const response = await adminApi.uploadTicketFile(file);
replyAttachments.value.push(response.data);
}
ElMessage.success("附件上传成功");
} catch (error) {
console.error(error);
ElMessage.error("附件上传失败");
} finally {
attachmentUploading.value = false;
target.value = "";
}
}
async function removePendingAttachment(fileUrl: string) {
try {
await adminApi.deleteTicketFile(fileUrl);
replyAttachments.value = replyAttachments.value.filter((item) => item.file_url !== fileUrl);
ElMessage.success("附件已删除");
} catch (error) {
console.error(error);
ElMessage.error("附件删除失败");
}
}
onMounted(fetchAll);
</script>
<template>
<div v-loading="loading">
<div class="metric-grid" style="margin-bottom: 18px">
<div v-for="item in cards" :key="item.title" class="metric-card">
<div class="metric-card__label">{{ item.title }}</div>
<div class="metric-card__value">{{ item.value }}</div>
<div class="metric-card__desc">{{ item.desc }}</div>
</div>
</div>
<el-card class="panel-card" shadow="never">
<div class="filters-row">
<el-input v-model="keyword" placeholder="搜索工单号 / 标题" clearable style="width: 320px" />
<el-select v-model="ticketType" placeholder="工单类型" style="width: 180px">
<el-option v-for="item in typeOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
<el-select v-model="status" placeholder="工单状态" style="width: 150px">
<el-option v-for="item in statusOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
<el-button type="primary" @click="fetchAll">查询</el-button>
</div>
</el-card>
<el-card class="panel-card orders-table" shadow="never">
<el-table :data="tickets" stripe>
<el-table-column prop="ticket_no" label="工单号" min-width="160" />
<el-table-column prop="title" label="标题" min-width="220" />
<el-table-column prop="ticket_type_text" label="工单类型" min-width="120" />
<el-table-column label="状态" min-width="100">
<template #default="{ row }">
<OrderStatusTag :status="row.status_text" />
</template>
</el-table-column>
<el-table-column prop="priority_text" label="优先级" min-width="100" />
<el-table-column prop="order_id" label="订单ID" min-width="100" />
<el-table-column prop="updated_at" label="更新时间" min-width="170" />
<el-table-column label="操作" fixed="right" width="110">
<template #default="{ row }">
<el-button link type="primary" @click="openDetail(row)">查看详情</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
<el-drawer v-model="drawerVisible" size="52%" title="工单详情">
<div v-loading="detailLoading" v-if="detail" class="detail-grid">
<div class="detail-card">
<div class="detail-card__title">工单概览</div>
<div class="detail-card__desc">
<div class="detail-label">工单号 / 类型</div>
<div class="detail-value">{{ detail.ticket_info.ticket_no }} / {{ detail.ticket_info.ticket_type_text }}</div>
</div>
<div class="detail-card__desc">
<div class="detail-label">状态 / 优先级</div>
<div class="detail-value">{{ detail.ticket_info.status_text }} / {{ detail.ticket_info.priority_text }}</div>
</div>
<div class="detail-card__desc">
<div class="detail-label">业务关联</div>
<div class="detail-value">{{ detail.ticket_info.biz_type }} / {{ detail.ticket_info.biz_id }}</div>
</div>
<div class="detail-card__desc">
<div class="detail-label">工单处理</div>
<div style="display:flex; gap:12px; margin-top:8px; flex-wrap:wrap;">
<el-select v-model="ticketForm.status" style="width: 160px">
<el-option label="待处理" value="pending" />
<el-option label="处理中" value="processing" />
<el-option label="待用户反馈" value="waiting_user" />
<el-option label="已解决" value="resolved" />
<el-option label="已关闭" value="closed" />
</el-select>
<el-select v-model="ticketForm.priority" style="width: 140px">
<el-option label="高优先级" value="high" />
<el-option label="普通" value="normal" />
<el-option label="低优先级" value="low" />
</el-select>
<el-button type="primary" :loading="ticketSubmitting" @click="submitTicket">更新工单</el-button>
</div>
</div>
</div>
<div class="detail-card">
<div class="detail-card__title">问题描述</div>
<div class="detail-card__desc">
<div class="detail-label">标题</div>
<div class="detail-value">{{ detail.ticket_info.title }}</div>
</div>
<div class="detail-card__desc">
<div class="detail-label">内容</div>
<div class="detail-value">{{ detail.ticket_info.content || "-" }}</div>
</div>
</div>
<div class="detail-card" style="grid-column: 1 / -1">
<div class="detail-card__title">工单留言</div>
<div class="timeline-list" style="margin-top: 14px">
<div v-for="item in detail.messages" :key="`${item.sender_type}-${item.created_at}`" class="timeline-node">
<div class="timeline-node__title">{{ item.sender_type_text }}</div>
<div class="timeline-node__time">{{ item.created_at }}</div>
<div class="timeline-node__desc">{{ item.content || "-" }}</div>
<div v-if="item.attachments.length" class="admin-upload-list" style="margin-top: 12px">
<div
v-for="attachment in item.attachments"
:key="attachment.file_id"
class="admin-upload-thumb"
@click="previewAttachment(attachment.file_url)"
>
<img :src="attachment.thumbnail_url" alt="工单附件" />
</div>
</div>
</div>
</div>
<div style="margin-top: 16px">
<el-input v-model="replyContent" type="textarea" :rows="4" placeholder="输入客服回复内容" />
<input ref="uploadInput" type="file" accept="image/*" multiple style="display: none" @change="handleFileSelect" />
<div v-if="replyAttachments.length" class="admin-upload-list" style="margin-top: 12px">
<div
v-for="attachment in replyAttachments"
:key="attachment.file_id"
style="display:flex; flex-direction:column; gap:8px;"
>
<div class="admin-upload-thumb" @click="previewAttachment(attachment.file_url)">
<img :src="attachment.thumbnail_url" alt="待发送附件" />
</div>
<el-button text type="danger" @click="removePendingAttachment(attachment.file_url)">删除</el-button>
</div>
</div>
<div style="display:flex; justify-content:flex-end; margin-top:12px;">
<el-button :loading="attachmentUploading" @click="triggerUpload">上传附件</el-button>
<el-button type="primary" :loading="replySubmitting" @click="submitReply">发送回复</el-button>
</div>
</div>
</div>
</div>
</el-drawer>
</div>
</template>

View File

@@ -0,0 +1,232 @@
<script setup lang="ts">
import { onMounted, reactive, ref } from "vue";
import { ElMessage } from "element-plus";
import {
adminApi,
type AdminUserDetail,
type AdminUserItem,
type AdminUserOverviewCard,
type AdminUserPayload,
} from "../../api/admin";
import OrderStatusTag from "../../components/OrderStatusTag.vue";
const loading = ref(false);
const detailLoading = ref(false);
const userSubmitting = ref(false);
const drawerVisible = ref(false);
const userDialogVisible = ref(false);
const cards = ref<AdminUserOverviewCard[]>([]);
const users = ref<AdminUserItem[]>([]);
const detail = ref<AdminUserDetail | null>(null);
const keyword = ref("");
const status = ref("");
const userForm = reactive<AdminUserPayload>({
nickname: "",
mobile: "",
status: "enabled",
password: "",
});
const statusOptions = [
{ label: "全部状态", value: "" },
{ label: "正常", value: "enabled" },
{ label: "已停用", value: "disabled" },
];
async function fetchAll() {
loading.value = true;
try {
const [overviewRes, usersRes] = await Promise.all([
adminApi.getUserOverview(),
adminApi.getUsers({
keyword: keyword.value,
status: status.value,
}),
]);
cards.value = overviewRes.data.cards;
users.value = usersRes.data.list;
} catch (error) {
console.error(error);
ElMessage.error("用户管理数据加载失败");
} finally {
loading.value = false;
}
}
async function openDetail(row: AdminUserItem) {
drawerVisible.value = true;
detailLoading.value = true;
try {
const response = await adminApi.getUserDetail(row.id);
detail.value = response.data;
} catch (error) {
console.error(error);
ElMessage.error("用户详情加载失败");
} finally {
detailLoading.value = false;
}
}
function openUserDialog(row?: AdminUserItem) {
if (row) {
userForm.id = row.id;
userForm.nickname = row.nickname;
userForm.mobile = row.mobile;
userForm.status = row.status;
userForm.password = "";
} else {
userForm.id = undefined;
userForm.nickname = "";
userForm.mobile = "";
userForm.status = "enabled";
userForm.password = "";
}
userDialogVisible.value = true;
}
async function submitUser() {
userSubmitting.value = true;
try {
await adminApi.saveUser({ ...userForm });
ElMessage.success(userForm.id ? "用户更新成功" : "用户创建成功");
userDialogVisible.value = false;
await fetchAll();
} catch (error) {
console.error(error);
ElMessage.error("用户保存失败");
} finally {
userSubmitting.value = false;
}
}
onMounted(fetchAll);
</script>
<template>
<div v-loading="loading">
<div class="metric-grid" style="margin-bottom: 18px">
<div v-for="item in cards" :key="item.title" class="metric-card">
<div class="metric-card__label">{{ item.title }}</div>
<div class="metric-card__value">{{ item.value }}</div>
<div class="metric-card__desc">{{ item.desc }}</div>
</div>
</div>
<el-card class="panel-card" shadow="never">
<div class="filters-row">
<el-input v-model="keyword" placeholder="搜索昵称 / 手机号" clearable style="width: 320px" />
<el-select v-model="status" placeholder="用户状态" style="width: 160px">
<el-option v-for="item in statusOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
<el-button type="primary" @click="fetchAll">查询</el-button>
<el-button @click="openUserDialog()">新增用户</el-button>
</div>
</el-card>
<el-card class="panel-card orders-table" shadow="never">
<el-table :data="users" stripe>
<el-table-column prop="nickname" label="昵称" min-width="160" />
<el-table-column prop="mobile" label="手机号" min-width="140" />
<el-table-column label="状态" min-width="110">
<template #default="{ row }">
<OrderStatusTag :status="row.status_text" />
</template>
</el-table-column>
<el-table-column prop="default_address" label="默认地址" min-width="260" />
<el-table-column prop="order_count" label="订单数" min-width="90" />
<el-table-column prop="message_count" label="消息数" min-width="90" />
<el-table-column prop="ticket_count" label="工单数" min-width="90" />
<el-table-column prop="created_at" label="创建时间" min-width="170" />
<el-table-column label="操作" fixed="right" width="150">
<template #default="{ row }">
<el-button link type="primary" @click="openDetail(row)">详情</el-button>
<el-button link type="warning" @click="openUserDialog(row)">编辑</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
<el-drawer v-model="drawerVisible" size="54%" title="用户详情">
<div v-loading="detailLoading" v-if="detail" class="detail-grid">
<div class="detail-card">
<div class="detail-card__title">用户概览</div>
<div class="detail-card__desc">
<div class="detail-label">昵称 / 手机号</div>
<div class="detail-value">{{ detail.user_info.nickname }} / {{ detail.user_info.mobile }}</div>
</div>
<div class="detail-card__desc">
<div class="detail-label">状态</div>
<div class="detail-value">{{ detail.user_info.status_text }} / {{ detail.user_info.password_set ? '已设置登录密码' : '未设置登录密码' }}</div>
</div>
<div class="detail-card__desc">
<div class="detail-label">注册时间</div>
<div class="detail-value">{{ detail.user_info.created_at }}</div>
</div>
</div>
<div class="detail-card">
<div class="detail-card__title">地址信息</div>
<div v-for="item in detail.addresses" :key="`${item.full_address}-${item.mobile}`" class="detail-card__desc">
<div class="detail-label">{{ item.is_default ? "默认地址" : "地址" }}</div>
<div class="detail-value">{{ item.consignee }} / {{ item.mobile }}</div>
<div class="detail-value" style="font-weight: 400">{{ item.full_address }}</div>
</div>
</div>
<div class="detail-card" style="grid-column: 1 / -1">
<div class="detail-card__title">最近订单</div>
<div class="timeline-list" style="margin-top: 14px">
<div v-for="item in detail.recent_orders" :key="`${item.order_no}-${item.created_at}`" class="timeline-node">
<div class="timeline-node__title">{{ item.order_no }}</div>
<div class="timeline-node__time">{{ item.created_at }}</div>
<div class="timeline-node__desc">{{ item.display_status }} / ¥{{ item.pay_amount }}</div>
</div>
</div>
</div>
<div class="detail-card" style="grid-column: 1 / -1">
<div class="detail-card__title">最近消息</div>
<div class="timeline-list" style="margin-top: 14px">
<div v-for="item in detail.recent_messages" :key="`${item.title}-${item.created_at}`" class="timeline-node">
<div class="timeline-node__title">{{ item.title }}</div>
<div class="timeline-node__time">{{ item.created_at }}</div>
<div class="timeline-node__desc">{{ item.content }}</div>
</div>
</div>
</div>
</div>
</el-drawer>
<el-dialog v-model="userDialogVisible" :title="userForm.id ? '编辑用户' : '新增用户'" width="520px">
<el-form label-position="top">
<el-form-item label="昵称">
<el-input v-model="userForm.nickname" placeholder="请输入昵称" />
</el-form-item>
<el-form-item label="手机号">
<el-input v-model="userForm.mobile" placeholder="请输入手机号" />
</el-form-item>
<el-form-item label="状态">
<el-radio-group v-model="userForm.status">
<el-radio value="enabled">正常</el-radio>
<el-radio value="disabled">停用</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item :label="userForm.id ? '重置登录密码' : '登录密码'">
<el-input
v-model="userForm.password"
type="password"
show-password
:placeholder="userForm.id ? '如需重置密码请填写,留空则不修改' : '可选,留空则仅支持验证码登录'"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="userDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="userSubmitting" @click="submitUser">保存</el-button>
</template>
</el-dialog>
</div>
</template>

View File

@@ -0,0 +1,306 @@
<script setup lang="ts">
import { onMounted, reactive, ref } from "vue";
import { ElMessage } from "element-plus";
import {
adminApi,
type AdminWarehouseItem,
type AdminWarehouseOverviewCard,
type AdminWarehousePayload,
} from "../../api/admin";
import OrderStatusTag from "../../components/OrderStatusTag.vue";
const loading = ref(false);
const submitting = ref(false);
const dialogVisible = ref(false);
const cards = ref<AdminWarehouseOverviewCard[]>([]);
const warehouses = ref<AdminWarehouseItem[]>([]);
const categories = ref<Array<{ id: number; name: string }>>([]);
const serviceAreaProvincesText = ref("");
const serviceAreaCitiesText = ref("");
const form = reactive<AdminWarehousePayload>({
warehouse_name: "",
warehouse_code: "",
service_provider: "anxinyan",
receiver_name: "",
receiver_mobile: "",
province: "广东省",
city: "深圳市",
district: "南山区",
detail_address: "",
service_time: "周一至周日 09:30-18:30",
notice: "",
supported_category_ids: [],
service_area_provinces: [],
service_area_cities: [],
status: "enabled",
is_default: true,
sort_order: 0,
remark: "",
});
async function fetchAll() {
loading.value = true;
try {
const [overviewRes, warehousesRes] = await Promise.all([
adminApi.getWarehouseOverview(),
adminApi.getWarehouses(),
]);
cards.value = overviewRes.data.cards;
warehouses.value = warehousesRes.data.list;
categories.value = warehousesRes.data.category_options;
} catch (error) {
console.error(error);
ElMessage.error("仓库中心数据加载失败");
} finally {
loading.value = false;
}
}
function parseAreaText(value: string) {
return value
.split(/[\n,]/)
.map((item) => item.trim())
.filter(Boolean);
}
function openDialog(row?: AdminWarehouseItem) {
if (row) {
form.id = row.id;
form.warehouse_name = row.warehouse_name;
form.warehouse_code = row.warehouse_code;
form.service_provider = row.service_provider;
form.receiver_name = row.receiver_name;
form.receiver_mobile = row.receiver_mobile;
form.province = row.province;
form.city = row.city;
form.district = row.district;
form.detail_address = row.detail_address;
form.service_time = row.service_time;
form.notice = row.notice;
form.supported_category_ids = [...row.supported_category_ids];
form.service_area_provinces = [...row.service_area_provinces];
form.service_area_cities = [...row.service_area_cities];
form.status = row.status;
form.is_default = row.is_default;
form.sort_order = row.sort_order;
form.remark = row.remark;
serviceAreaProvincesText.value = row.service_area_provinces.join("");
serviceAreaCitiesText.value = row.service_area_cities.join("");
} else {
form.id = undefined;
form.warehouse_name = "";
form.warehouse_code = "";
form.service_provider = "anxinyan";
form.receiver_name = "";
form.receiver_mobile = "";
form.province = "广东省";
form.city = "深圳市";
form.district = "南山区";
form.detail_address = "";
form.service_time = "周一至周日 09:30-18:30";
form.notice = "";
form.supported_category_ids = [];
form.service_area_provinces = [];
form.service_area_cities = [];
form.status = "enabled";
form.is_default = true;
form.sort_order = 0;
form.remark = "";
serviceAreaProvincesText.value = "";
serviceAreaCitiesText.value = "";
}
dialogVisible.value = true;
}
async function submit() {
submitting.value = true;
try {
await adminApi.saveWarehouse({
...form,
service_area_provinces: parseAreaText(serviceAreaProvincesText.value),
service_area_cities: parseAreaText(serviceAreaCitiesText.value),
});
ElMessage.success(form.id ? "仓库已更新" : "仓库已创建");
dialogVisible.value = false;
await fetchAll();
} catch (error) {
console.error(error);
ElMessage.error("仓库保存失败");
} finally {
submitting.value = false;
}
}
onMounted(fetchAll);
</script>
<template>
<div v-loading="loading">
<div class="metric-grid" style="margin-bottom: 18px">
<div v-for="item in cards" :key="item.title" class="metric-card">
<div class="metric-card__label">{{ item.title }}</div>
<div class="metric-card__value">{{ item.value }}</div>
<div class="metric-card__desc">{{ item.desc }}</div>
</div>
</div>
<el-card class="panel-card" shadow="never">
<div class="filters-row" style="justify-content: space-between;">
<div style="color: var(--admin-text-subtle);">
维护用户寄送页展示的收货仓库与检测中心地址当前按服务类型匹配默认仓库并预留按品类扩展能力
</div>
<el-button type="primary" @click="openDialog()">新增仓库</el-button>
</div>
</el-card>
<el-card class="panel-card orders-table" shadow="never">
<el-table :data="warehouses" stripe>
<el-table-column prop="warehouse_name" label="仓库名称" min-width="180" />
<el-table-column prop="warehouse_code" label="仓库编码" min-width="150" />
<el-table-column prop="service_provider_text" label="服务归属" min-width="120" />
<el-table-column prop="receiver_name" label="收件人" min-width="120" />
<el-table-column prop="receiver_mobile" label="联系电话" min-width="130" />
<el-table-column prop="full_address" label="地址" min-width="260" />
<el-table-column label="适用品类" min-width="220">
<template #default="{ row }">
<el-space wrap>
<el-tag v-if="row.supported_category_names.length === 0" type="info" round>全部品类</el-tag>
<el-tag v-for="item in row.supported_category_names" :key="item" type="warning" round>{{ item }}</el-tag>
</el-space>
</template>
</el-table-column>
<el-table-column label="服务地区" min-width="220">
<template #default="{ row }">
<el-space wrap>
<el-tag v-if="row.service_area_provinces.length === 0 && row.service_area_cities.length === 0" type="info" round>全国推荐</el-tag>
<el-tag v-for="item in row.service_area_provinces" :key="`province-${item}`" round>{{ item }}</el-tag>
<el-tag v-for="item in row.service_area_cities" :key="`city-${item}`" type="success" round>{{ item }}</el-tag>
</el-space>
</template>
</el-table-column>
<el-table-column label="状态" min-width="140">
<template #default="{ row }">
<OrderStatusTag :status="row.is_default ? `${row.status_text} / 默认` : row.status_text" />
</template>
</el-table-column>
<el-table-column prop="service_time" label="服务时间" min-width="180" />
<el-table-column label="操作" fixed="right" width="100">
<template #default="{ row }">
<el-button link type="primary" @click="openDialog(row)">编辑</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
<el-dialog v-model="dialogVisible" :title="form.id ? '编辑仓库' : '新增仓库'" width="720px">
<el-form label-position="top">
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="仓库名称">
<el-input v-model="form.warehouse_name" placeholder="请输入仓库名称" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="仓库编码">
<el-input v-model="form.warehouse_code" placeholder="可留空,系统自动生成" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="服务归属">
<el-select v-model="form.service_provider" style="width: 100%">
<el-option label="实物鉴定" value="anxinyan" />
<el-option label="中检鉴定" value="zhongjian" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="排序值">
<el-input v-model.number="form.sort_order" type="number" placeholder="越小越靠前" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="收件人">
<el-input v-model="form.receiver_name" placeholder="请输入收件人" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="联系电话">
<el-input v-model="form.receiver_mobile" placeholder="请输入联系电话" />
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="省份">
<el-input v-model="form.province" placeholder="请输入省份" />
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="城市">
<el-input v-model="form.city" placeholder="请输入城市" />
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="区县">
<el-input v-model="form.district" placeholder="请输入区县" />
</el-form-item>
</el-col>
<el-col :span="24">
<el-form-item label="详细地址">
<el-input v-model="form.detail_address" placeholder="请输入详细地址" />
</el-form-item>
</el-col>
<el-col :span="24">
<el-form-item label="服务时间">
<el-input v-model="form.service_time" placeholder="例如:周一至周日 09:30-18:30" />
</el-form-item>
</el-col>
<el-col :span="24">
<el-form-item label="适用品类">
<el-select v-model="form.supported_category_ids" multiple collapse-tags collapse-tags-tooltip style="width: 100%" placeholder="不选则代表全部品类">
<el-option v-for="item in categories" :key="item.id" :label="item.name" :value="item.id" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="24">
<el-form-item label="寄送提示">
<el-input v-model="form.notice" type="textarea" :rows="4" placeholder="请输入寄送须知、单号说明等文案" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="推荐省份">
<el-input v-model="serviceAreaProvincesText" type="textarea" :rows="4" placeholder="多个省份可用逗号或换行分隔;留空代表不限制省份" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="推荐城市">
<el-input v-model="serviceAreaCitiesText" type="textarea" :rows="4" placeholder="多个城市可用逗号或换行分隔;优先级高于省份" />
</el-form-item>
</el-col>
<el-col :span="24">
<el-form-item label="备注">
<el-input v-model="form.remark" placeholder="可填写仓库说明、备用信息等" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="状态">
<el-radio-group v-model="form.status">
<el-radio value="enabled">启用</el-radio>
<el-radio value="disabled">停用</el-radio>
</el-radio-group>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="默认仓库">
<el-switch v-model="form.is_default" inline-prompt active-text="默认" inactive-text="普通" />
</el-form-item>
</el-col>
</el-row>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" :loading="submitting" @click="submit">保存</el-button>
</template>
</el-dialog>
</div>
</template>

View File

@@ -0,0 +1,258 @@
import { createRouter, createWebHashHistory } from "vue-router";
import { adminApi } from "../api/admin";
import { clearAdminSession, getAdminInfo, getAdminToken, hasPermission, setAdminInfo } from "../utils/auth";
const adminChildren = [
{
path: "dashboard",
name: "dashboard",
component: () => import("../pages/dashboard/index.vue"),
meta: {
title: "工作台",
desc: "查看当前订单、报告与处理进度概览。",
permission: "dashboard.view",
},
},
{
path: "orders",
name: "orders",
component: () => import("../pages/orders/index.vue"),
meta: {
title: "订单中心",
desc: "管理订单流转、查看补图任务与处理详情。",
permission: "orders.manage",
},
},
{
path: "appraisal-tasks",
name: "appraisal-tasks",
component: () => import("../pages/appraisal-tasks/index.vue"),
meta: {
title: "鉴定作业台",
desc: "查看鉴定任务、资料详情与当前作业结论。",
permission: "appraisal_tasks.manage",
},
},
{
path: "catalog",
name: "catalog",
component: () => import("../pages/catalog/index.vue"),
meta: {
title: "商品资料中心",
desc: "查看品类、品牌、上传模板与鉴定模板等基础配置数据。",
permission: "catalog.manage",
},
},
{
path: "reports",
name: "reports",
component: () => import("../pages/reports/index.vue"),
meta: {
title: "报告中心",
desc: "查看已生成报告、报告状态与验真信息。",
permission: "reports.manage",
},
},
{
path: "messages",
name: "messages",
component: () => import("../pages/messages/index.vue"),
meta: {
title: "消息中心",
desc: "查看消息模板、触发规则与发送记录概览。",
permission: "messages.manage",
},
},
{
path: "tickets",
name: "tickets",
component: () => import("../pages/tickets/index.vue"),
meta: {
title: "客服与售后",
desc: "查看工单、用户留言与售后处理记录。",
permission: "tickets.manage",
},
},
{
path: "users",
name: "users",
component: () => import("../pages/users/index.vue"),
meta: {
title: "用户管理",
desc: "查看用户资料、地址、消息和工单等用户侧资产概览。",
permission: "users.manage",
},
},
{
path: "customers",
name: "customers",
component: () => import("../pages/customers/index.vue"),
meta: {
title: "客户管理",
desc: "维护大客户资料、开放接口应用 Key、订单映射和 Webhook 推送记录。",
permission: "customers.manage",
},
},
{
path: "warehouses",
name: "warehouses",
component: () => import("../pages/warehouses/index.vue"),
meta: {
title: "仓库中心",
desc: "维护收货仓库、检测中心地址信息,并为后续多仓库扩展预留能力。",
permission: "warehouses.manage",
},
},
{
path: "materials",
name: "materials",
component: () => import("../pages/materials/index.vue"),
meta: {
title: "物料管理",
desc: "批量生成吊牌二维码,管理批次下载、条码搜索与报告绑定状态。",
permission: "materials.manage",
},
},
{
path: "access",
name: "access",
component: () => import("../pages/access/index.vue"),
meta: {
title: "权限中心",
desc: "管理管理员账号、角色配置与权限点分配。",
permission: "access.manage",
},
},
{
path: "content",
name: "content",
component: () => import("../pages/content/index.vue"),
redirect: { name: "content-home" },
meta: {
title: "内容中心",
desc: "维护首页展示内容与帮助中心文章等用户端内容配置。",
permission: "system.manage",
},
children: [
{
path: "home",
name: "content-home",
component: () => import("../pages/content/home.vue"),
meta: {
title: "内容中心",
desc: "首页内容维护Banner、服务入口、快捷入口和信任信息。",
permission: "system.manage",
menuIndex: "content",
contentTab: "home",
},
},
{
path: "policy",
name: "content-policy",
component: () => import("../pages/content/policy.vue"),
meta: {
title: "内容中心",
desc: "协议与说明维护:设置页说明入口和下单确认协议。",
permission: "system.manage",
menuIndex: "content",
contentTab: "policy",
},
},
{
path: "meta",
name: "content-meta",
component: () => import("../pages/content/meta.vue"),
meta: {
title: "内容中心",
desc: "分类与文案维护:帮助分类、消息事件、工单文案和风险提示。",
permission: "system.manage",
menuIndex: "content",
contentTab: "meta",
},
},
{
path: "articles",
name: "content-articles",
component: () => import("../pages/content/articles.vue"),
meta: {
title: "内容中心",
desc: "帮助文章维护:文章正文、推荐状态和排序。",
permission: "system.manage",
menuIndex: "content",
contentTab: "articles",
},
},
],
},
{
path: "system-config",
name: "system-config",
component: () => import("../pages/system-config/index.vue"),
meta: {
title: "系统配置",
desc: "配置小程序、H5、支付与商户平台等上线核心参数。",
permission: "system.manage",
},
},
];
const router = createRouter({
history: createWebHashHistory(),
routes: [
{
path: "/login",
name: "login",
component: () => import("../pages/login/index.vue"),
meta: {
public: true,
title: "登录",
},
},
{
path: "/",
component: () => import("../layouts/AdminLayout.vue"),
redirect: "/dashboard",
children: adminChildren,
},
],
});
function firstAccessibleRoute() {
const target = adminChildren.find((route) => hasPermission(route.meta.permission as string));
return target?.name || "dashboard";
}
router.beforeEach(async (to) => {
if (to.meta.public) {
if (getAdminToken()) {
return { name: firstAccessibleRoute() };
}
return true;
}
const token = getAdminToken();
if (!token) {
clearAdminSession();
return { name: "login" };
}
if (!getAdminInfo()) {
try {
const response = await adminApi.getAuthMe();
setAdminInfo(response.data.admin_info);
} catch (error) {
console.error(error);
clearAdminSession();
return { name: "login" };
}
}
const permission = to.meta.permission as string | undefined;
if (permission && !hasPermission(permission)) {
return { name: firstAccessibleRoute() };
}
return true;
});
export default router;

446
admin-web/src/style.css Normal file
View File

@@ -0,0 +1,446 @@
:root {
font-family: "PingFang SC", "Microsoft YaHei", sans-serif;
color: #1f2430;
background: #f5f6f8;
line-height: 1.5;
font-weight: 400;
color-scheme: light;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
--admin-brand-black: #171717;
--admin-brand-gold: #c8a45d;
--admin-page-bg: #f5f6f8;
--admin-card-bg: #ffffff;
--admin-border: #e9e2d2;
--admin-text-main: #1f2430;
--admin-text-subtle: #6d7483;
--admin-success: #2f6b4f;
--admin-warning: #b67a2d;
--admin-danger: #9f3b32;
--admin-progress: #486885;
--admin-neutral: #667085;
}
* {
box-sizing: border-box;
}
html,
body,
#app {
margin: 0;
min-height: 100%;
background: var(--admin-page-bg);
}
body {
min-width: 1200px;
color: var(--admin-text-main);
}
.login-page {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 32px;
background:
radial-gradient(circle at top right, rgba(200, 164, 93, 0.18), transparent 22%),
linear-gradient(160deg, #111111 0%, #171717 48%, #272117 100%);
}
.login-card {
width: 100%;
max-width: 460px;
padding: 32px 30px 28px;
border-radius: 28px;
background: linear-gradient(180deg, #ffffff 0%, #fbf7ef 100%);
border: 1px solid rgba(200, 164, 93, 0.24);
box-shadow: 0 28px 80px rgba(0, 0, 0, 0.22);
}
.login-card__eyebrow {
display: inline-flex;
align-items: center;
min-height: 32px;
padding: 0 14px;
border-radius: 999px;
background: rgba(200, 164, 93, 0.14);
color: #7a5a21;
font-size: 12px;
font-weight: 600;
}
.login-card__title {
margin-top: 18px;
font-size: 30px;
font-weight: 800;
line-height: 1.1;
}
.login-card__desc {
margin-top: 10px;
color: var(--admin-text-subtle);
font-size: 14px;
line-height: 1.6;
}
.login-card__hint {
margin-top: 8px;
color: var(--admin-text-subtle);
font-size: 12px;
}
.login-card__action {
width: 100%;
margin-top: 18px;
}
a {
color: inherit;
text-decoration: none;
}
.admin-layout {
min-height: 100vh;
background: linear-gradient(180deg, #f8f8f8 0%, #f2f3f5 100%);
}
.admin-aside {
position: relative;
border-right: 1px solid rgba(255, 255, 255, 0.06);
background:
radial-gradient(circle at top right, rgba(200, 164, 93, 0.14), transparent 24%),
linear-gradient(180deg, #111111 0%, #171717 50%, #1f1b14 100%);
}
.admin-brand {
padding: 28px 24px 18px;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
.admin-brand__name {
color: #fff;
font-size: 24px;
font-weight: 700;
}
.admin-brand__desc {
margin-top: 8px;
color: rgba(255, 255, 255, 0.6);
font-size: 13px;
}
.admin-aside .el-menu {
border-right: none;
background: transparent;
--el-menu-bg-color: transparent;
--el-menu-text-color: rgba(255, 255, 255, 0.72);
--el-menu-hover-text-color: #ffffff;
--el-menu-active-color: #ffffff;
--el-menu-hover-bg-color: rgba(200, 164, 93, 0.18);
}
.admin-aside .el-menu-item,
.admin-aside .el-sub-menu__title {
color: rgba(255, 255, 255, 0.72);
height: 52px;
transition:
color 0.2s ease,
background-color 0.2s ease,
box-shadow 0.2s ease;
}
.admin-aside .el-menu-item .el-icon,
.admin-aside .el-sub-menu__title .el-icon {
color: inherit;
transition: color 0.2s ease;
}
.admin-aside .el-menu-item:hover,
.admin-aside .el-menu-item:focus,
.admin-aside .el-sub-menu__title:hover,
.admin-aside .el-sub-menu__title:focus {
color: #fff;
background:
linear-gradient(90deg, rgba(200, 164, 93, 0.26) 0%, rgba(200, 164, 93, 0.1) 92%);
box-shadow: inset 0 0 0 1px rgba(200, 164, 93, 0.1);
}
.admin-aside .el-menu-item.is-active {
color: #fff;
background:
linear-gradient(90deg, rgba(200, 164, 93, 0.24) 0%, rgba(200, 164, 93, 0.12) 92%);
box-shadow:
inset 0 0 0 1px rgba(200, 164, 93, 0.12),
0 12px 28px rgba(0, 0, 0, 0.14);
}
.admin-main {
padding: 24px;
}
.admin-topbar {
display: flex;
justify-content: space-between;
align-items: center;
gap: 20px;
padding: 22px 24px;
border: 1px solid var(--admin-border);
border-radius: 24px;
background: linear-gradient(135deg, #ffffff 0%, #fbf7ef 100%);
box-shadow: 0 10px 30px rgba(15, 23, 42, 0.04);
}
.admin-topbar__title {
font-size: 26px;
font-weight: 700;
}
.admin-topbar__desc {
margin-top: 6px;
color: var(--admin-text-subtle);
font-size: 14px;
}
.admin-topbar__meta {
display: flex;
gap: 12px;
flex-wrap: wrap;
}
.admin-chip {
display: inline-flex;
align-items: center;
min-height: 34px;
padding: 0 14px;
border-radius: 999px;
background: rgba(200, 164, 93, 0.12);
color: #7a5a21;
font-size: 12px;
font-weight: 600;
}
.admin-content {
margin-top: 20px;
}
.panel-card {
border: 1px solid var(--admin-border);
border-radius: 22px;
background: var(--admin-card-bg);
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.04);
}
.panel-card + .panel-card {
margin-top: 18px;
}
.metric-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 18px;
}
.metric-card {
padding: 22px;
border: 1px solid var(--admin-border);
border-radius: 20px;
background: linear-gradient(180deg, #ffffff 0%, #fbf8f1 100%);
}
.metric-card__label {
color: var(--admin-text-subtle);
font-size: 13px;
}
.metric-card__value {
margin-top: 12px;
font-size: 34px;
font-weight: 700;
line-height: 1;
}
.metric-card__desc {
margin-top: 12px;
color: var(--admin-text-subtle);
font-size: 13px;
}
.filters-row {
display: flex;
gap: 12px;
align-items: center;
flex-wrap: wrap;
}
.orders-table .el-table {
--el-table-border-color: #f0eadf;
--el-table-header-bg-color: #fbf8f2;
--el-table-row-hover-bg-color: #fcfaf5;
border-radius: 16px;
}
.status-tag {
--status-tag-color: var(--admin-neutral);
--status-tag-bg: rgba(102, 112, 133, 0.1);
--status-tag-border: rgba(102, 112, 133, 0.16);
--status-tag-glow: rgba(102, 112, 133, 0.14);
display: inline-flex;
align-items: center;
gap: 8px;
min-height: 30px;
padding: 0 12px 0 10px;
border-radius: 999px;
font-size: 12px;
font-weight: 700;
line-height: 1;
letter-spacing: 0.01em;
white-space: nowrap;
color: var(--status-tag-color);
border: 1px solid var(--status-tag-border);
background: var(--status-tag-bg);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.72),
0 1px 2px rgba(17, 24, 39, 0.04);
vertical-align: middle;
}
.status-tag::before {
content: "";
width: 6px;
height: 6px;
border-radius: 50%;
background: currentColor;
box-shadow: 0 0 0 4px var(--status-tag-glow);
}
.status-tag--success {
--status-tag-color: var(--admin-success);
--status-tag-bg: linear-gradient(180deg, rgba(47, 107, 79, 0.12) 0%, rgba(47, 107, 79, 0.08) 100%);
--status-tag-border: rgba(47, 107, 79, 0.16);
--status-tag-glow: rgba(47, 107, 79, 0.16);
}
.status-tag--warning {
--status-tag-color: var(--admin-warning);
--status-tag-bg: linear-gradient(180deg, rgba(182, 122, 45, 0.14) 0%, rgba(182, 122, 45, 0.09) 100%);
--status-tag-border: rgba(182, 122, 45, 0.18);
--status-tag-glow: rgba(182, 122, 45, 0.18);
}
.status-tag--danger {
--status-tag-color: var(--admin-danger);
--status-tag-bg: linear-gradient(180deg, rgba(159, 59, 50, 0.12) 0%, rgba(159, 59, 50, 0.08) 100%);
--status-tag-border: rgba(159, 59, 50, 0.16);
--status-tag-glow: rgba(159, 59, 50, 0.16);
}
.status-tag--progress {
--status-tag-color: var(--admin-progress);
--status-tag-bg: linear-gradient(180deg, rgba(72, 104, 133, 0.14) 0%, rgba(72, 104, 133, 0.09) 100%);
--status-tag-border: rgba(72, 104, 133, 0.16);
--status-tag-glow: rgba(72, 104, 133, 0.16);
}
.status-tag--neutral {
--status-tag-color: var(--admin-neutral);
--status-tag-bg: linear-gradient(180deg, rgba(102, 112, 133, 0.12) 0%, rgba(102, 112, 133, 0.08) 100%);
--status-tag-border: rgba(102, 112, 133, 0.14);
--status-tag-glow: rgba(102, 112, 133, 0.14);
}
.detail-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
}
.detail-card {
padding: 18px 18px 16px;
border: 1px solid var(--admin-border);
border-radius: 18px;
background: #fffdfa;
}
.detail-card__title {
font-size: 15px;
font-weight: 700;
}
.detail-card__desc {
margin-top: 12px;
}
.detail-card__desc + .detail-card__desc {
margin-top: 10px;
}
.detail-label {
color: var(--admin-text-subtle);
font-size: 13px;
}
.detail-value {
margin-top: 4px;
color: var(--admin-text-main);
font-size: 14px;
font-weight: 600;
}
.timeline-list {
display: flex;
flex-direction: column;
gap: 14px;
}
.timeline-node {
padding: 14px 16px;
border-radius: 16px;
background: #fcfaf5;
border: 1px solid #efe8d9;
}
.timeline-node__title {
font-size: 14px;
font-weight: 700;
}
.timeline-node__time {
margin-top: 6px;
color: var(--admin-text-subtle);
font-size: 12px;
}
.timeline-node__desc {
margin-top: 8px;
color: var(--admin-text-main);
font-size: 13px;
}
.admin-upload-list {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-top: 12px;
}
.admin-upload-thumb {
width: 84px;
height: 84px;
border-radius: 14px;
overflow: hidden;
border: 1px solid #eadfc8;
background: #f6f3ec;
cursor: pointer;
}
.admin-upload-thumb img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}

View File

@@ -0,0 +1,54 @@
const TOKEN_KEY = "anxinyan_admin_token";
const ADMIN_INFO_KEY = "anxinyan_admin_info";
export interface AdminSessionInfo {
id: number;
name: string;
mobile: string;
email: string;
status: string;
role_names: string[];
permission_codes: string[];
}
export function getAdminToken() {
return localStorage.getItem(TOKEN_KEY) || "";
}
export function setAdminToken(token: string) {
localStorage.setItem(TOKEN_KEY, token);
}
export function clearAdminToken() {
localStorage.removeItem(TOKEN_KEY);
}
export function getAdminInfo(): AdminSessionInfo | null {
const raw = localStorage.getItem(ADMIN_INFO_KEY);
if (!raw) return null;
try {
return JSON.parse(raw) as AdminSessionInfo;
} catch {
return null;
}
}
export function setAdminInfo(info: AdminSessionInfo) {
localStorage.setItem(ADMIN_INFO_KEY, JSON.stringify(info));
}
export function clearAdminInfo() {
localStorage.removeItem(ADMIN_INFO_KEY);
}
export function clearAdminSession() {
clearAdminToken();
clearAdminInfo();
}
export function hasPermission(code?: string) {
if (!code) return true;
const info = getAdminInfo();
if (!info) return false;
return info.permission_codes.includes(code);
}

View File

@@ -0,0 +1,24 @@
const LOCAL_API_BASE_URL = "http://127.0.0.1:8787";
function isLocalLikeHostname(hostname: string) {
return (
hostname === "localhost" ||
hostname === "127.0.0.1" ||
hostname === "0.0.0.0" ||
/^10\./.test(hostname) ||
/^192\.168\./.test(hostname) ||
/^172\.(1[6-9]|2\d|3[0-1])\./.test(hostname)
);
}
export function resolveApiBaseUrl() {
if (import.meta.env.DEV) {
return LOCAL_API_BASE_URL;
}
if (typeof window !== "undefined" && isLocalLikeHostname(window.location.hostname)) {
return LOCAL_API_BASE_URL;
}
return import.meta.env.VITE_API_BASE_URL || LOCAL_API_BASE_URL;
}

View File

@@ -0,0 +1,33 @@
import type { Router } from "vue-router";
let appRouter: Router | null = null;
export function setAppRouter(router: Router) {
appRouter = router;
}
export function goToAdminLogin() {
if (appRouter) {
if (appRouter.currentRoute.value.name !== "login") {
appRouter.replace({ name: "login" });
}
return;
}
if (window.location.hash !== "#/login") {
window.history.replaceState({}, "", "/#/login");
window.dispatchEvent(new PopStateEvent("popstate"));
}
}
export function goToAdminHome() {
if (appRouter) {
appRouter.replace({ name: "dashboard" });
return;
}
if (window.location.hash !== "#/dashboard") {
window.history.replaceState({}, "", "/#/dashboard");
window.dispatchEvent(new PopStateEvent("popstate"));
}
}