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

24
.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
.DS_Store
# dependencies
node_modules/
vendor/
# runtime
runtime/
dist/
unpackage/
# env
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
# IDE
.idea/
.vscode/
# logs
*.log

73
README.md Normal file
View File

@@ -0,0 +1,73 @@
# 安心验鉴定平台
安心验是一个面向奢侈品 / 潮流品鉴定履约场景的平台项目,当前仓库包含:
- 用户端 H5 / 小程序共用前端
- 管理后台
- `webman` 后端 API
- 部署、流程、数据库与产品文档
## 目录说明
- `server-api`
后端服务,负责订单、仓库、履约、报告、消息、工单等业务接口
- `user-app`
uni-app 用户端H5 与小程序共用代码
- `admin-web`
Vue 3 管理后台
- `docs`
产品、流程、接口、部署与交付文档
## 技术栈
- 后端PHP 8.1+ / webman / MySQL / Redis
- 用户端uni-app / Vue 3 / TypeScript / Pinia
- 后台Vue 3 / Vite / TypeScript / Element Plus
## 当前已完成主链路
- 用户下单与资料上传
- 用户提交送检运单
- 后台标记鉴定中心签收
- 鉴定补料与单次鉴定
- 报告出具与验真
- 用户确认寄回地址
- 后台登记回寄运单
- 后台标记用户签收
- 用户消息中心通知联动
## 本地常用命令
### 后端
```bash
cd server-api
php start.php start -d
php start.php reload -d
php tools/smoke_check.php
php tools/release_audit.php
```
### 用户端
```bash
cd user-app
npm run dev:h5
npm run type-check
npm run build:h5
```
### 管理后台
```bash
cd admin-web
npm run build
```
## 推荐先看文档
- [履约状态机](/Users/wushumin/www/biyou/anxinyan/docs/flow/state-machine.md)
- [上线检查清单](/Users/wushumin/www/biyou/anxinyan/docs/deploy/release-checklist.md)
- [履约冒烟检查表](/Users/wushumin/www/biyou/anxinyan/docs/deploy/fulfillment-smoke-checklist.md)
- [当前交付说明](/Users/wushumin/www/biyou/anxinyan/docs/deploy/delivery-notes.md)
- [部署说明](/Users/wushumin/www/biyou/anxinyan/docs/deploy/deploy-plan.md)

View File

@@ -0,0 +1,3 @@
VITE_API_BASE_URL=http://127.0.0.1:8787
VITE_APP_ENV=development
VITE_APP_TITLE=安心验管理后台

3
admin-web/.env.example Normal file
View File

@@ -0,0 +1,3 @@
VITE_API_BASE_URL=http://127.0.0.1:8787
VITE_APP_ENV=development
VITE_APP_TITLE=安心验管理后台

View File

@@ -0,0 +1,3 @@
VITE_API_BASE_URL=https://api.anxinjianyan.com
VITE_APP_ENV=production
VITE_APP_TITLE=安心验管理后台

3
admin-web/.env.test Normal file
View File

@@ -0,0 +1,3 @@
VITE_API_BASE_URL=https://test-api.example.com
VITE_APP_ENV=test
VITE_APP_TITLE=安心验管理后台

24
admin-web/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

5
admin-web/README.md Normal file
View File

@@ -0,0 +1,5 @@
# Vue 3 + TypeScript + Vite
This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
Learn more about the recommended Project Setup and IDE Support in the [Vue Docs TypeScript Guide](https://vuejs.org/guide/typescript/overview.html#project-setup).

13
admin-web/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>admin-web</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

2625
admin-web/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

30
admin-web/package.json Normal file
View File

@@ -0,0 +1,30 @@
{
"name": "admin-web",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"@element-plus/icons-vue": "^2.3.2",
"@types/qrcode": "^1.5.6",
"axios": "^1.15.0",
"echarts": "^6.0.0",
"element-plus": "^2.13.7",
"pinia": "^3.0.4",
"qrcode": "^1.5.4",
"vue": "^3.5.32",
"vue-router": "^5.0.4"
},
"devDependencies": {
"@types/node": "^24.12.2",
"@vitejs/plugin-vue": "^6.0.5",
"@vue/tsconfig": "^0.9.1",
"typescript": "~6.0.2",
"vite": "^8.0.4",
"vue-tsc": "^3.2.6"
}
}

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

View File

@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

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"));
}
}

View File

@@ -0,0 +1,14 @@
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"types": ["vite/client"],
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
}

7
admin-web/tsconfig.json Normal file
View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View File

@@ -0,0 +1,24 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023"],
"module": "esnext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["vite.config.ts"]
}

44
admin-web/vite.config.ts Normal file
View File

@@ -0,0 +1,44 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
// https://vite.dev/config/
export default defineConfig({
plugins: [vue()],
server: {
host: '0.0.0.0',
port: 5174,
strictPort: true,
},
preview: {
host: '0.0.0.0',
port: 4174,
strictPort: true,
},
build: {
rollupOptions: {
output: {
manualChunks(id) {
if (!id.includes('node_modules')) {
return
}
if (id.includes('element-plus') || id.includes('@element-plus')) {
return 'vendor-element-plus'
}
if (id.includes('echarts')) {
return 'vendor-echarts'
}
if (id.includes('axios')) {
return 'vendor-axios'
}
if (id.includes('vue-router') || id.includes('pinia') || id.includes('/vue/')) {
return 'vendor-vue'
}
},
},
},
},
})

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

5
docs/api/api-list.md Normal file
View File

@@ -0,0 +1,5 @@
# API List
## 第三方开放接口
- [第三方订单对接文档](./third-party-openapi.md)客户推送订单、订单查询、签名鉴权、Webhook 回调说明。

View File

@@ -0,0 +1,385 @@
# 第三方订单对接文档
版本v1
更新日期2026-05-08
## 1. 对接说明
本文档用于第三方系统对接安心验开放接口。第三方推送订单时,只需要提供第三方自己的订单号 `external_order_no`,不需要提前传物品信息。具体物品信息会在鉴定师鉴定时由平台侧补充完善。
接口域名以实际环境为准,本文统一使用:
```text
https://{api-domain}
```
## 2. 凭证与安全
平台会为每个对接方分配:
| 参数 | 说明 |
| --- | --- |
| `app_key` | 调用方身份标识 |
| `app_secret` | 签名密钥,请妥善保管,不要传给前端或泄露到日志中 |
所有开放接口都需要签名。请求必须使用 HTTPS并携带以下请求头
| Header | 必填 | 说明 |
| --- | --- | --- |
| `Content-Type` | 是 | 固定为 `application/json` |
| `X-AXY-App-Key` | 是 | 平台分配的 `app_key` |
| `X-AXY-Timestamp` | 是 | Unix 秒级时间戳,有效期 300 秒 |
| `X-AXY-Nonce` | 是 | 随机字符串,同一个 `app_key` 下不可重复使用 |
| `X-AXY-Signature` | 是 | 请求签名 |
### 2.1 签名算法
签名使用 HMAC-SHA256小写十六进制输出。
```text
body_hash = sha256(raw_body)
base = UPPERCASE_HTTP_METHOD + path_with_query + timestamp + nonce + body_hash
signature = hex_hmac_sha256(base, app_secret)
```
字段说明:
| 字段 | 说明 |
| --- | --- |
| `raw_body` | 原始请求体字符串。GET 请求没有请求体时为空字符串 |
| `path_with_query` | 请求路径加查询字符串,例如 `/api/open/v1/orders?external_order_no=T202605080001` |
| `timestamp` | 与 `X-AXY-Timestamp` 完全一致 |
| `nonce` | 与 `X-AXY-Nonce` 完全一致 |
注意事项:
- `path_with_query` 必须与实际请求完全一致,包括查询参数顺序和 URL 编码。
- POST 请求签名时使用实际发送的 JSON 字符串,不要签名一个格式化版本、发送另一个压缩版本。
- GET 请求的 `body_hash``sha256("")`
- `nonce` 会做防重放校验,重试请求需要重新生成 `nonce` 和签名。
### 2.2 Node.js 签名示例
```js
import crypto from 'node:crypto';
function sign({ method, pathWithQuery, body = '', timestamp, nonce, appSecret }) {
const bodyHash = crypto.createHash('sha256').update(body).digest('hex');
const base = method.toUpperCase() + pathWithQuery + timestamp + nonce + bodyHash;
return crypto.createHmac('sha256', appSecret).update(base).digest('hex');
}
```
### 2.3 PHP 签名示例
```php
function sign_request(string $method, string $pathWithQuery, string $body, string $timestamp, string $nonce, string $appSecret): string
{
$bodyHash = hash('sha256', $body);
$base = strtoupper($method) . $pathWithQuery . $timestamp . $nonce . $bodyHash;
return hash_hmac('sha256', $base, $appSecret);
}
```
## 3. 通用响应格式
成功响应:
```json
{
"code": 0,
"message": "ok",
"data": {}
}
```
失败响应:
```json
{
"code": 422,
"message": "external_order_no 不能为空",
"data": {}
}
```
常见错误码:
| code | 说明 |
| --- | --- |
| `401` | 鉴权失败、签名错误、时间戳过期、`nonce` 重复 |
| `404` | 订单不存在 |
| `409` | 幂等冲突,例如同一个 `external_order_no` 请求内容不一致 |
| `422` | 请求参数不合法 |
| `500` | 服务端处理失败 |
## 4. 创建订单
```text
POST /api/open/v1/orders
```
第三方创建订单时只需要传 `external_order_no`。平台会创建一笔待收货订单,后续物品信息由鉴定师在鉴定工作台补充。
### 4.1 请求参数
| 字段 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| `external_order_no` | string | 是 | 第三方订单号。同一对接客户下必须唯一 |
| `service_provider` | string | 否 | 服务方,可选 `anxinyan``zhongjian`,默认 `anxinyan` |
| `product_info` | object | 否 | 物品信息,当前可不传 |
| `materials` | array | 否 | 鉴定资料图片 URL 列表,当前可不传 |
| `return_address` | object | 否 | 退回地址,当前可不传;如传任一地址字段,则必填完整地址 |
| `inbound_logistics` | object | 否 | 寄入物流信息,当前可不传 |
| `express_company` | string | 否 | 寄入快递公司,可替代 `inbound_logistics.express_company` |
| `tracking_no` | string | 否 | 寄入运单号,可替代 `inbound_logistics.tracking_no` |
| `extra_info` | object | 否 | 扩展信息,当前可不传 |
### 4.2 最小请求示例
```json
{
"external_order_no": "THIRD202605080001"
}
```
### 4.3 带可选字段请求示例
```json
{
"external_order_no": "THIRD202605080002",
"service_provider": "anxinyan",
"inbound_logistics": {
"express_company": "顺丰速运",
"tracking_no": "SF1234567890"
},
"return_address": {
"consignee": "张三",
"mobile": "13800138000",
"province": "浙江省",
"city": "杭州市",
"district": "西湖区",
"detail_address": "文三路 1 号"
}
}
```
### 4.4 cURL 示例
```bash
curl -X POST 'https://{api-domain}/api/open/v1/orders' \
-H 'Content-Type: application/json' \
-H 'X-AXY-App-Key: your_app_key' \
-H 'X-AXY-Timestamp: 1778227200' \
-H 'X-AXY-Nonce: 7b7b2a2f9c9e4d1f' \
-H 'X-AXY-Signature: calculated_signature' \
-d '{"external_order_no":"THIRD202605080001"}'
```
### 4.5 成功响应示例
```json
{
"code": 0,
"message": "订单已创建",
"data": {
"idempotent": false,
"order": {
"customer_id": "CUST001",
"customer_code": "CUST001",
"external_order_no": "THIRD202605080001",
"order_id": 123,
"order_no": "AXY20260508120000123",
"appraisal_no": "AXY-APP-20260508-1001",
"order_status": "pending_shipping",
"display_status": "待寄送商品",
"payment_status": "paid",
"pay_amount": 99,
"estimated_finish_time": "2026-05-09 12:00:00",
"created_at": "2026-05-08 12:00:00",
"timeline": [
{
"node_code": "created",
"node_text": "下单成功",
"node_desc": "大客户订单已推送并创建成功",
"occurred_at": "2026-05-08 12:00:00"
},
{
"node_code": "pending_shipping",
"node_text": "待寄送商品",
"node_desc": "请将商品寄送至鉴定中心",
"occurred_at": "2026-05-08 12:00:00"
}
],
"inbound_logistics": null,
"return_logistics": null,
"report_summary": null
}
}
}
```
### 4.6 幂等规则
同一个对接客户下,`external_order_no` 作为幂等键:
- 第一次请求会创建订单。
- 后续使用相同 `external_order_no` 且请求内容一致时,不会重复创建订单,会返回已有订单,`data.idempotent``true`
- 后续使用相同 `external_order_no` 但请求内容不一致时,返回 `409`
建议第三方重试创建订单时保持请求 JSON 内容一致,仅重新生成 `timestamp``nonce``signature`
## 5. 查询订单
支持按第三方订单号或平台订单号查询订单进度。
### 5.1 按第三方订单号查询
```text
GET /api/open/v1/orders/{external_order_no}
```
示例:
```bash
curl -X GET 'https://{api-domain}/api/open/v1/orders/THIRD202605080001' \
-H 'Content-Type: application/json' \
-H 'X-AXY-App-Key: your_app_key' \
-H 'X-AXY-Timestamp: 1778227200' \
-H 'X-AXY-Nonce: f0f74a6baf764d8f' \
-H 'X-AXY-Signature: calculated_signature'
```
### 5.2 通过查询参数查询
```text
GET /api/open/v1/orders?external_order_no=THIRD202605080001
GET /api/open/v1/orders?order_no=AXY20260508120000123
```
### 5.3 响应示例
```json
{
"code": 0,
"message": "ok",
"data": {
"order": {
"customer_id": "CUST001",
"customer_code": "CUST001",
"external_order_no": "THIRD202605080001",
"order_id": 123,
"order_no": "AXY20260508120000123",
"appraisal_no": "AXY-APP-20260508-1001",
"order_status": "report_published",
"display_status": "报告已发布",
"payment_status": "paid",
"pay_amount": 99,
"estimated_finish_time": "2026-05-09 12:00:00",
"created_at": "2026-05-08 12:00:00",
"timeline": [],
"inbound_logistics": {
"express_company": "顺丰速运",
"tracking_no": "SF1234567890",
"tracking_status": "submitted",
"latest_desc": "客户已提交寄送运单:顺丰速运 SF1234567890等待鉴定中心签收。",
"latest_time": "2026-05-08 12:00:00"
},
"return_logistics": null,
"report_summary": {
"report_no": "R202605080001",
"report_title": "鉴定报告",
"report_status": "published",
"publish_time": "2026-05-08 18:00:00",
"verify_url": "https://{h5-domain}/verify?id=xxx",
"report_page_url": "https://{h5-domain}/report/xxx",
"verify_status": "valid"
}
}
}
}
```
## 6. 订单状态
常见订单状态如下,最终以接口返回的 `order_status``display_status` 为准。
| order_status | display_status | 说明 |
| --- | --- | --- |
| `pending_shipping` | 待寄送商品 | 订单已创建,等待物品到仓或人工确认收货 |
| `received` | 鉴定中心已收货 | 物品已到仓 |
| `appraising` | 物品鉴定中 | 鉴定师正在鉴定 |
| `generating_report` | 物品鉴定完成 | 鉴定完成,报告生成中 |
| `report_published` | 报告已发布 | 报告已发布,可查看报告摘要 |
| `return_shipped` | 物品已寄回 | 物品已退回寄出 |
| `completed` | 已完成 | 订单完成 |
| `pending_supplement` | 需要补充资料 | 需要补充资料 |
## 7. Webhook 事件回调
如需接收订单状态变化通知,第三方需向平台提供可公网访问的 `webhook_url`,并由平台开启回调。
平台会以 POST JSON 方式推送事件:
| Header | 说明 |
| --- | --- |
| `Content-Type` | `application/json` |
| `X-AXY-App-Key` | 平台分配给该客户的 `app_key` |
当前 webhook 仅携带 `X-AXY-App-Key`,暂未实现回调签名。如第三方需要回调验签,可与平台另行约定后升级。
回调超时时间:
| 项目 | 值 |
| --- | --- |
| 连接超时 | 3 秒 |
| 总超时 | 6 秒 |
| 成功判定 | HTTP 状态码为 2xx 且无网络错误 |
### 7.1 回调报文
```json
{
"event_code": "order_created",
"event_text": "订单创建",
"customer_id": "CUST001",
"customer_code": "CUST001",
"external_order_no": "THIRD202605080001",
"order_no": "AXY20260508120000123",
"appraisal_no": "AXY-APP-20260508-1001",
"status_code": "pending_shipping",
"status_text": "待寄送商品",
"occurred_at": "2026-05-08 12:00:00",
"data": {},
"event_id": 1
}
```
### 7.2 事件类型
| event_code | event_text | status_code | status_text |
| --- | --- | --- | --- |
| `order_created` | 订单创建 | `pending_shipping` | 待寄送商品 |
| `inbound_received` | 快递已到仓 | `received` | 鉴定中心已收货 |
| `appraising` | 物品鉴定中 | `appraising` | 物品鉴定中 |
| `appraisal_finished` | 物品鉴定完成 | `generating_report` | 物品鉴定完成 |
| `report_published` | 报告已发布 | `report_published` | 报告已发布 |
| `return_shipped` | 物品已寄回 | `return_shipped` | 物品已寄回 |
| `completed` | 订单已完成 | `completed` | 已完成 |
| `supplement_required` | 需要补充资料 | `pending_supplement` | 需要补充资料 |
### 7.3 回调接收建议
第三方接收 webhook 时建议:
- 使用 `event_id` 做事件幂等,避免重复处理。
- 收到事件后返回 HTTP 2xx。
- 如需强一致的最新状态,可以收到 webhook 后再调用订单查询接口确认。
## 8. 对接流程建议
1. 平台分配 `app_key``app_secret`
2. 第三方完成签名调试。
3. 第三方调用创建订单接口,只传 `external_order_no` 即可。
4. 第三方可通过查询接口主动查询订单状态。
5. 如启用 webhook平台在订单状态变化时主动通知第三方。

View File

@@ -0,0 +1,3 @@
# ER Design
Pending fill-in based on the confirmed table structure.

View File

@@ -0,0 +1,17 @@
[Unit]
Description=Anxinyan webman API service
After=network.target
[Service]
Type=forking
WorkingDirectory=/www/wwwroot/anxinyan-api
ExecStart=/usr/bin/php /www/wwwroot/anxinyan-api/start.php start -d
ExecReload=/usr/bin/php /www/wwwroot/anxinyan-api/start.php reload -d
ExecStop=/usr/bin/php /www/wwwroot/anxinyan-api/start.php stop
Restart=always
RestartSec=3
User=www
Group=www
[Install]
WantedBy=multi-user.target

View File

@@ -0,0 +1,21 @@
server {
listen 80;
server_name api.anxinjianyan.com;
client_max_body_size 50m;
location / {
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 120s;
proxy_send_timeout 120s;
proxy_pass http://127.0.0.1:8787;
}
}
# 如已配置 HTTPS可在 443 server 中复用同样的 location 代理逻辑。

View File

@@ -0,0 +1,157 @@
# 安心验后端 API 线上部署说明
## 1. 当前部署目标
- API 域名:`api.anxinjianyan.com`
- 项目目录建议:`/www/wwwroot/anxinyan-api`
- 服务监听端口:`8787`
当前后端基于 `webman`,推荐部署方式:
1. 代码上传到服务器
2. 使用 `php start.php start -d` 启动常驻进程
3. 使用 `Nginx` 反向代理到 `127.0.0.1:8787`
4.`systemd` 管理进程自启动
## 2. 本次已准备好的发布包
发布包路径:
- [/Users/wushumin/www/biyou/anxinyan/releases/anxinyan-server-api-20260422.zip](/Users/wushumin/www/biyou/anxinyan/releases/anxinyan-server-api-20260422.zip)
建议上传后解压到:
```bash
mkdir -p /www/wwwroot/anxinyan-api
unzip anxinyan-server-api-20260422.zip -d /www/wwwroot/anxinyan-api
```
## 3. 服务器要求
- PHP 8.1+
- 扩展:
- `pdo`
- `pdo_mysql`
- `pcntl`
- 建议 `opcache`
- MySQL 8+
- Redis 6/7
- `Nginx`
- `systemd`
## 4. 部署步骤
### 4.1 上传并解压
```bash
cd /www/wwwroot
mkdir -p anxinyan-api
tar -xzf /path/to/anxinyan-server-api-20260422.tar.gz -C anxinyan-api
cd anxinyan-api
```
### 4.2 检查目录权限
```bash
mkdir -p runtime/logs storage/payment-certs
chmod -R 775 runtime storage
```
### 4.3 确认环境变量
发布包中已包含当前 `.env`,但上线前仍需人工确认:
- `APP_ENV=production`
- `APP_DEBUG=false`
- 数据库连接是否为正式库
- Redis 连接是否为正式实例
## 5. 启动命令
### 首次启动
```bash
cd /www/wwwroot/anxinyan-api
php start.php start -d
```
### 重载
```bash
cd /www/wwwroot/anxinyan-api
php start.php reload -d
```
### 停止
```bash
cd /www/wwwroot/anxinyan-api
php start.php stop
```
## 6. Nginx 配置
示例文件:
- [api.anxinjianyan.com.nginx.conf.example](/Users/wushumin/www/biyou/anxinyan/docs/deploy/api.anxinjianyan.com.nginx.conf.example)
核心逻辑:
- 对外监听 `80/443`
- 反向代理到 `127.0.0.1:8787`
- 保留真实 IP、协议头和 Host
## 7. systemd 配置
示例文件:
- [anxinyan-api.service.example](/Users/wushumin/www/biyou/anxinyan/docs/deploy/anxinyan-api.service.example)
放置路径建议:
```bash
/etc/systemd/system/anxinyan-api.service
```
启用命令:
```bash
systemctl daemon-reload
systemctl enable anxinyan-api
systemctl start anxinyan-api
systemctl status anxinyan-api
```
## 8. 上线后立即检查
### 接口联通
```bash
curl http://127.0.0.1:8787/
curl https://api.anxinjianyan.com/
```
### 冒烟验证
```bash
cd /www/wwwroot/anxinyan-api
php tools/smoke_check.php
php tools/release_audit.php
```
## 9. 当前仍需人工确认的阻塞项
- 小程序正式配置:
- `mini_program.app_id`
- `mini_program.app_secret`
- `mini_program.original_id`
## 10. 特别说明
- 当前数据库中的支付证书路径仍是本机绝对路径,若线上要用微信支付,建议在服务器后台重新上传:
- `apiclient_key.pem`
- `apiclient_cert.pem`
- 后台系统配置中的支付回调地址已调整为:
- `https://api.anxinjianyan.com`
- H5 页面根地址已调整为:
- `https://m.anxinjianyan.com`

View File

@@ -0,0 +1,138 @@
# 安心验当前交付说明
## 1. 当前交付范围
本阶段已覆盖以下端与能力:
- 用户端 H5
- 用户端小程序共用代码
- 管理后台
- 履约主流程
- 报告验真与下载
- 用户消息通知
本阶段不纳入:
- 上门鉴定复杂预约
- 积分商城
- 内容社区
- 直播 / 短视频
- 裂变分销
- 官网
- 商家后台
## 2. 已完成模块
### 用户端
- 登录
- 手机号 + 密码
- 手机号 + 验证码
- 发起鉴定
- 选择服务方式
- 选择商品信息
- 补充购买信息
- 上传鉴定资料
- 订单确认
- 订单中心
- 列表页
- 详情页
- 寄送页
- 补料页
- 报告中心
- 报告列表
- 报告详情
- 报告验真
- 消息中心
- 工单中心
- 地址管理
- 我的 / 设置
### 后台
- 管理员登录
- 订单中心
- 鉴定作业台
- 商品资料中心
- 报告中心
- 消息中心
- 客服与售后
- 用户管理
- 仓库中心
- 权限中心
- 系统配置
## 3. 已完成履约链路
### 送检链路
- 下单确认时选择寄回地址
- 订单创建后锁定送检仓库
- 用户可在寄送前切换检测中心
- 用户提交寄送运单
- 后台标记鉴定中心签收
### 鉴定链路
- 鉴定
- 补料发起
- 用户补料
- 报告生成
- 报告发布
### 寄回链路
- 用户确认寄回地址
- 后台登记回寄运单
- 用户端查看回寄物流
- 后台标记用户签收
- 消息中心同步回寄与签收通知
## 4. 历史兼容处理
当前代码已兼容以下历史数据问题:
- 老订单没有 `order_return_addresses` 快照时
- 用户端订单详情会自动回退展示默认地址
- 后台登记回寄运单时会自动用默认地址补写快照
- 历史 `verify_qrcode_url` 不是图片链接时
- 用户端报告详情会直接本地生成二维码 SVG
- 历史 `display_status` 与当前真实履约状态不一致时
- 用户端和后台订单列表会优先按真实物流状态推导展示
## 5. 当前仍建议人工重点确认
- 老订单回寄状态是否符合实际业务预期
- 订单列表中 `待寄回 / 物品已寄回 / 已完成` 的显示是否满足运营口径
- 管理后台权限是否满足正式分工
- 支付、短信、小程序正式配置是否完整
## 6. 当前已知上线前必须处理项
根据 `php tools/release_audit.php` 当前结果,当前仍剩以下未完成项:
- 小程序正式配置仍为空:
- `mini_program.app_id`
- `mini_program.app_secret`
- `mini_program.original_id`
已完成但需要运维知晓的变更:
- 后端 `.env` 已切换为生产开关:
- `APP_ENV=production`
- `APP_DEBUG=false`
- 前端生产 API 域名已替换为正式域名
- 测试管理员已停用
- 默认超级管理员密码已旋转,不再使用初始密码
## 7. 建议的最终上线动作
1.`tools/release_audit.php`
2. 在后台补齐小程序正式配置
3. 执行 `cd user-app && npm run sync:mp-config`
4. 再次运行 `tools/release_audit.php`
5. 修正审计结果中的剩余 `FAIL`
6.`tools/smoke_check.php`
7. 按 [fulfillment-smoke-checklist.md](/Users/wushumin/www/biyou/anxinyan/docs/deploy/fulfillment-smoke-checklist.md) 执行人工验收
8. 清理测试数据
9. 构建正式包并发布

130
docs/deploy/deploy-plan.md Normal file
View File

@@ -0,0 +1,130 @@
# 安心验部署说明
## 1. 项目结构
- `server-api`
技术栈PHP 8 + webman + MySQL + Redis
- `user-app`
技术栈uni-app + Vue 3 + TypeScript
产物:
- H5
- 微信小程序
- `admin-web`
技术栈Vue 3 + Vite + TypeScript + Element Plus
## 2. 当前已确认域名
- 用户端 H5`m.anxinjianyan.com`
- 后端 API`api.anxinjianyan.com`
- 管理后台:`admin.anxinjianyan.com`
说明:
- H5 页面根地址会用于生成报告页、验真页、扫码跳转链接
- API 域名会用于 H5、后台、小程序请求
## 3. 本地常用命令
### 后端
```bash
cd /Users/wushumin/www/biyou/anxinyan/server-api
php start.php start -d
php start.php reload -d
php tools/smoke_check.php
php tools/release_audit.php
```
### 用户端 H5
```bash
cd /Users/wushumin/www/biyou/anxinyan/user-app
npm run dev:h5
npm run type-check
npm run build:h5
```
### 管理后台
```bash
cd /Users/wushumin/www/biyou/anxinyan/admin-web
npm run build
```
### 小程序配置同步
```bash
cd /Users/wushumin/www/biyou/anxinyan/user-app
npm run sync:mp-config
npm run build:mp-weixin
```
## 4. 部署顺序建议
1. 导入数据库结构与种子数据
2. 执行 schema 升级脚本
3. 配置后端 `.env`
4. 在后台完成系统配置
5. 构建并部署 `admin-web`
6. 构建并部署 `user-app` H5
7. 同步小程序 AppID 并构建小程序包
8.`smoke_check.php`
9. 执行人工履约链路验收
## 5. 当前必须执行的 schema 升级
已存在脚本:
- `php tools/schema_upgrade_warehouses.php`
- `php tools/schema_upgrade_order_shipping_targets.php`
- `php tools/schema_upgrade_order_return_flow.php`
- `php tools/schema_upgrade_manual_reports.php`
- `php tools/schema_upgrade_user_login_sms.php`
建议在正式环境按上述顺序执行一次。
## 6. 后台必须配置的分组
- 小程序配置
- H5 配置
- 短信配置
- 微信支付 / 商户平台配置
其中:
- H5 根地址必须指向正式域名
- 小程序 AppID 必须同步到 `manifest.json`
- 支付证书和商户密钥必须在后台上传或保存
## 7. 部署后必须验证的主链路
- 用户下单
- 用户提交寄送运单
- 后台标记鉴定中心签收
- 后台发起补料
- 用户补料
- 后台发布报告
- 用户确认寄回地址
- 后台登记回寄运单
- 后台标记用户签收
- 用户报告验真
## 8. 当前状态
当前代码库已经具备:
- 用户端主流程
- 后台订单履约主流程
- 多仓库 / 改派仓库
- 补料任务
- 报告发布与验真
- 寄回地址确认
- 回寄运单登记
- 用户签收闭环
剩余工作更偏向:
- 正式环境配置
- 测试数据清理
- 人工验收
- 上线前口径确认

View File

@@ -0,0 +1,113 @@
# 安心验履约链路冒烟检查表
## 目标
确认用户端、后台、消息中心围绕以下主链路已经闭环:
1. 用户下单
2. 用户寄送商品并提交运单
3. 鉴定中心签收
4. 鉴定中 / 补料
5. 报告出具
6. 用户确认寄回地址
7. 后台登记回寄运单
8. 用户签收回寄商品
## 自动检查
先执行:
```bash
cd /Users/wushumin/www/biyou/anxinyan/server-api
php tools/smoke_check.php
```
预期:
- 输出 `SMOKE_OK`
- `app``admin` 关键接口全部通过
- 报告详情接口包含 `verify_qrcode_url`
## 人工检查
### 1. 新建订单
- 用户端发起一笔新订单
- 在确认订单页必须可以选择“寄回地址”
- 未选择寄回地址时,不能提交订单
- 订单创建成功后,订单详情应显示:
- 收货仓库
- 寄回地址
- 下单资料
### 2. 用户寄送
- 订单状态应为 `待寄送``已提交运单`
- 用户寄送页应只展示“寄往鉴定中心”物流
- 提交运单后:
- 订单详情提示改为“等待鉴定中心签收”
- 订单列表状态应显示 `已提交运单`
### 3. 鉴定中心签收
- 后台订单详情点击“标记鉴定中心签收”
- 订单状态应变为 `鉴定中心已收货`
- 用户端订单详情同步显示已签收
### 4. 补料
- 后台发起补料后:
- 用户端订单状态应显示 `等待您补充资料`
- 鉴定作业台任务状态应显示 `待用户补料`
- 不得出现 `已退回` 这类容易误解成回寄商品的文案
### 5. 报告出具
- 后台发布报告后:
- 用户端报告中心出现报告
- 报告详情页显示验真二维码
- 订单状态应显示 `待寄回`
- 消息中心收到“报告已出具”
### 6. 用户确认寄回地址
- 订单详情页 `寄回给您` 区块应显示地址
- 老订单若没有寄回快照,应自动回退显示用户默认地址
- 用户可在回寄前修改寄回地址
### 7. 后台登记回寄运单
- 后台订单详情点击“登记回寄运单”
- 若订单没有寄回地址快照,但用户有默认地址,应能自动补写后继续登记
- 登记成功后:
- 订单状态应显示 `物品已寄回`
- 用户端订单详情显示回寄物流
- 消息中心收到“鉴定物品已寄回”
### 8. 标记用户签收
- 后台订单详情点击“标记用户签收”
- 完成后:
- 订单状态应显示 `已完成`
- 回寄物流状态应为 `用户已签收`
- 消息中心收到“回寄商品已签收”
- 用户端订单详情应提示本次订单已完成
## 当前已实现的关键点
- 下单确认时选择寄回地址
- 订单详情展示寄回地址与回寄物流
- 后台登记回寄运单
- 后台标记用户签收
- 回寄消息通知联动
- 用户端与后台订单状态口径统一
- 报告详情二维码显示
## 仍建议重点人工确认
- 历史老订单在没有寄回地址快照时的展示是否符合预期
- `completed` 状态下,列表页是否准确区分:
- 物品已寄回
- 已完成
- 用户消息中心的回寄通知点击跳转是否总是进入正确订单
- 报告页二维码在 H5 与小程序环境下都能正常扫描

View File

@@ -0,0 +1,67 @@
# 安心验上线检查清单
## 1. 环境变量
- 替换 [server-api/.env.example](/Users/wushumin/www/biyou/anxinyan/server-api/.env.example) 中的数据库、Redis 等占位值
- 确认 `APP_ENV=production`
- 确认 `APP_DEBUG=false`
- 确认 [admin-web/.env.production](/Users/wushumin/www/biyou/anxinyan/admin-web/.env.production) 与 [user-app/.env.production](/Users/wushumin/www/biyou/anxinyan/user-app/.env.production) 指向正式 API 域名,而不是 `localhost / 127.0.0.1 / example.com`
## 2. 后台系统配置
- 在后台 `系统配置` 中填写并保存:
- 小程序 `AppID / AppSecret / 原始ID`
- H5 `AppID / AppSecret / OAuth 回调地址 / H5 页面根地址`
- 短信 `阿里云 AccessKey ID / AccessKey Secret / 短信签名 / 登录模板 Code / Region ID`
- 支付 `MchID / APIv3 Key / 商户证书序列号 / 商户私钥 / 平台证书序列号 / 支付回调地址`
- 严禁保留演示值:
- `wx1234567890test`
- `h5_app_demo`
- `1900000109`
- `demo_api_v3_key_1234567890`
## 3. 管理后台安全
- 修改默认超级管理员密码:
- `13800138000 / Admin@123456`
- 删除或停用测试管理员:
- `13800138001 / Test@123456`
- 按实际运营需要分配角色与权限
## 4. 业务数据清理
- 清理测试工单、测试订单、测试物流、测试消息
- 清理 `server-api/public/uploads/` 下测试图片和 PDF
- 确认用户昵称、地址等演示数据已替换或清空
## 5. 构建与回归
- 后端执行:
- `php tools/smoke_check.php`
- 前端执行:
- `cd user-app && npm run type-check`
- `cd user-app && npm run build:h5`
- `cd admin-web && npm run build`
- 核验关键链路:
- 用户端下单 -> 提交运单 -> 补资料 -> 报告 -> 验真
- 用户工单 -> 客服回复 -> 消息提醒
- 后台登录 -> 权限控制 -> 系统配置保存
## 6. 微信相关
- 在后台 `系统配置` 保存正式小程序 `AppID` 后,执行:
- `cd user-app && npm run sync:mp-config`
- 再执行:
- `cd user-app && npm run build:mp-weixin`
- 构建前确认 [user-app/src/manifest.json](/Users/wushumin/www/biyou/anxinyan/user-app/src/manifest.json) 中 `mp-weixin.appid` 已同步为正式值
- 确认后台 `H5 页面根地址` 指向正式 H5 域名,例如 `https://m.example.com`,用于生成扫码查看报告和验真页链接
- H5 授权域名、支付域名、回调域名已在微信平台完成配置
- 微信支付商户平台证书与 APIv3 Key 已完成正式部署
## 7. 短信登录
- 在后台 `系统配置 -> 短信配置` 中填写阿里云短信参数
- 确认短信签名与登录模板已在阿里云短信服务中审核通过
- 确认登录模板包含 `code` 变量
- 正式环境下验证:
- 非微信浏览器 H5 可通过 `手机号 + 验证码` 登录
- 已设置密码的账号可通过 `手机号 + 密码` 登录
## 8. 发布前建议
- 先跑一遍 `tools/release_audit.php`
- 如需打包小程序,先跑一遍 `npm run sync:mp-config`
- 审核巡检输出中的 `FAIL / WARN`
- 完成替换后再做最终上线

126
docs/flow/state-machine.md Normal file
View File

@@ -0,0 +1,126 @@
# 安心验履约状态机
## 1. 订单主状态
### 用户提交前
- `pending_payment`
说明:订单待支付,当前项目里基本不作为主流履约状态使用。
- `pending_submission`
说明:待补充下单资料,尚未进入正式送检流转。
### 用户寄送阶段
- `pending_shipping`
说明:订单已创建,等待用户寄送商品到鉴定中心。
典型展示:
- 未填运单:`待寄送商品`
- 已填运单:`已提交运单`
- `received`
说明:鉴定中心已签收商品,等待进入鉴定处理。
### 鉴定阶段
- `in_first_review`
说明:鉴定处理中。
- `pending_supplement`
说明:鉴定师发起补料,等待用户补交资料。
- `generating_report`
说明:已完成鉴定,正在生成报告。
### 报告与寄回阶段
- `report_published`
说明:报告已发布,等待平台安排寄回商品。
典型展示:`待寄回`
- `completed`
说明:订单已完成。
注意:`completed` 下根据回寄物流再细分展示:
- 已登记回寄运单但用户未签收:`物品已寄回`
- 用户已签收回寄商品:`已完成`
## 2. 鉴定任务状态
### 任务阶段
- `first_review`
说明:鉴定任务
### 任务状态值
- `pending`
对外文案:`待处理`
- `processing`
对外文案:`处理中`
- `returned`
对外文案:`待用户补料`
注意:这是任务被打回补料,不是货品寄回用户。
- `submitted`
对外文案:`已提交`
- `completed`
对外文案:`已完成`
## 3. 物流状态
### 物流类型
- `send_to_center`
说明:用户寄送到鉴定中心
- `return_to_user`
说明:平台回寄给用户
### 物流节点状态
#### 用户寄送物流
- `submitted`
文案:`已提交运单`
- `in_transit`
文案:`运输中`
- `received`
文案:`已签收`
#### 回寄物流
- `submitted`
文案:`已登记回寄运单`
- `in_transit`
文案:`回寄途中`
- `received`
文案:`用户已签收`
## 4. 关键状态迁移
### 下单到鉴定
1. 用户创建订单
2. 订单进入 `pending_shipping`
3. 用户提交寄送运单
4. 后台标记鉴定中心签收
5. 订单进入 `received`
6. 鉴定任务进入 `processing`
### 补料分支
1. 鉴定师发起补料
2. 当前任务状态改为 `returned`
3. 订单状态改为 `pending_supplement`
4. 用户补料完成后,订单重新进入 `in_first_review`
### 出报告到寄回
1. 后台发布报告
2. 订单进入 `report_published`
3. 用户确认寄回地址
4. 后台登记回寄运单
5. 订单进入 `completed`
6. 若回寄物流未签收,对外显示 `物品已寄回`
7. 后台标记用户签收后,对外显示 `已完成`
## 5. 当前关键口径
- “补料”只能表示资料补充,不得使用“退回”对外表达。
- “待寄回”表示报告已出具但平台尚未登记回寄运单。
- “物品已寄回”表示平台已登记回寄运单,但用户尚未签收。
- “已完成”只用于回寄商品已签收,或无需回寄的最终完成态。
- 订单报告未发布前,不允许登记回寄运单或安排物品寄回。

3
docs/prd/mvp-prd.md Normal file
View File

@@ -0,0 +1,3 @@
# MVP PRD
Pending fill-in based on the confirmed product scope.

View File

@@ -0,0 +1,226 @@
# 安心验静态数据与展示映射审计
审计时间2026-04-23
## 一、已确认的真实问题
### 1. 后台订单详情页存在“接口有值但页面没展示完整”的问题
- 文件:`admin-web/src/pages/orders/index.vue`
- 现象:
- 接口已返回 `product_info.color`
- 接口已返回 `extra_info.condition_desc` / `remark`
- 页面原先只展示了 `size_spec``usage_status`
- 导致后台看到的内容与接口实际内容不一致,且卡片出现大面积空白
- 本轮已修复:
- 商品信息区补充 `颜色 / 规格`
- 补充信息区补充 `补充说明`
### 2. 鉴定作业台也存在同类字段遗漏
- 文件:`admin-web/src/pages/appraisal-tasks/index.vue`
- 现象:
- 页面已有 `product_info.color`
- 但工作区与上下文区未展示,导致检测侧看到的信息不完整
- 本轮已修复:
- 商品与送检区补充 `颜色 / 规格`
- 工作台左侧上下文区补充 `颜色 / 规格`
### 3. 用户端多个页面在接口失败时会静默回退到 mock 数据
- 风险级别:高
- 问题本质:
- 页面请求失败后,没有给用户明确错误态
- 而是继续显示 `src/mocks/app.ts` 中的示例数据
- 会造成“接口已经失败,但页面仍像有真实数据”的错觉
- 这类问题比普通静态文案更危险,因为会直接影响业务判断
## 二、前端仍在使用 mock / fallback 的页面
### 1. 用户端高风险页面
以下页面在请求失败时,会直接沿用 mock 或 fallback 数据:
- `user-app/src/pages/home/index.vue`
- `user-app/src/pages/address/index.vue`
- `user-app/src/pages/message/index.vue`
- `user-app/src/pages/mine/index.vue`
- `user-app/src/pages/order/detail.vue`
- `user-app/src/pages/order/shipping.vue`
- `user-app/src/pages/report/detail.vue`
- `user-app/src/pages/help/index.vue`
- `user-app/src/pages/help/detail.vue`
- `user-app/src/pages/settings/index.vue`
- `user-app/src/pages/support/index.vue`
- `user-app/src/pages/support/detail.vue`
- `user-app/src/pages/support/create.vue`
- `user-app/src/pages/verify/result.vue`
统一来源:
- `user-app/src/mocks/app.ts`
### 2. 风险说明
- 订单、报告、消息、地址、工单、验真都属于真实业务数据
- 这些页面不适合在接口失败时展示示例数据
- 正确策略应该是:
- 显示错误态
- 提供重试按钮
- 保留空态,但不能伪造业务内容
## 三、后端接口本身仍是静态拼装的数据
### 1. 首页接口
- 文件:`server-api/app/controller/app/HomeController.php`
- 当前静态内容:
- `banners`
- `service_entries`
- `quick_entries`
- `trust_metrics`
- `trust_points`
- `faqs`
- 当前只有 `category_entries` 来自数据库
补充说明:
- `user-app/src/pages/home/index.vue` 原先没有消费 `banners`
- 即使接口已经返回首页头图文案,前端页面也仍然使用写死标题
- 本轮已修复为直接读取接口 `banners[0]`
### 2. 帮助中心接口
- 文件:`server-api/app/controller/app/HelpCenterController.php`
- 当前问题:
- 帮助分类与文章内容全部写在控制器里
- 搜索和分类筛选只是对本地数组做过滤
- 结论:
- 当前帮助中心已经“可用”,但本质仍是写死内容,不是后台可运营内容
### 3. 设置页协议入口
- 文件:`server-api/app/controller/app/SettingsController.php`
- 当前静态内容:
- `legal_entries`
- 跳转的帮助中心关键词
### 4. 下单预览协议文案
- 文件:`server-api/app/controller/app/AppraisalController.php`
- 当前静态内容:
- `preview()` 中的 `agreements`
- `service_agreement`
- `privacy_policy`
- `appraisal_notice`
## 四、前端仍然硬编码的业务选项
这些不一定是 bug但如果要达到“后台可维护、线上可运营”的标准后续建议逐步改成可配置或接口下发。
### 1. 用户端
- `user-app/src/pages/support/create.vue`
- 工单类型 `typeOptions`
- `user-app/src/pages/support/index.vue`
- 常见问题快捷入口 `quickTypes`
- `user-app/src/pages/appraisal/product.vue`
- 颜色建议 `colorSuggestions`
- 规格建议 `sizeSuggestions`
- `user-app/src/pages/appraisal/extra.vue`
- 购买渠道
- 使用情况
- 附件情况
### 2. 管理后台
- `admin-web/src/pages/orders/index.vue`
- 服务筛选项
- 状态筛选项
- `admin-web/src/pages/appraisal-tasks/index.vue`
- 阶段筛选项
- 状态筛选项
- 结果选项
- `admin-web/src/pages/reports/index.vue`
- 服务筛选项
- 报告状态项
- 结果项
- `admin-web/src/pages/tickets/index.vue`
- 工单类型与状态筛选
说明:
- 这类枚举型选项短期内可以先保留
- 但工单类型、结果模板、服务文案如果未来需要运营调整,建议迁移为后台字典表或系统配置
## 五、已发现的“假入口 / 假交互”
- `user-app/src/pages/appraisal/upload.vue`
- `查看示例` 按钮无实际功能
- `查看拍摄示例` 入口无实际功能
- `user-app/src/pages/home/index.vue`
- 未识别的快捷入口会落到“该功能正在完善中”
- `user-app/src/pages/mine/index.vue`
- 未识别入口也会落到“该功能正在完善中”
## 六、推荐开发顺序
### P0先清掉会误导真实业务判断的 fallback
优先处理以下页面:
- 订单详情
- 报告详情
- 消息中心
- 地址管理
- 工单列表 / 工单详情
- 设置页
- 验真页
目标:
- 失败时显示明确错误状态,不再显示 mock 业务数据
### P1把接口里已返回但页面没展示完整的字段补齐
优先处理以下模块:
- 后台订单详情
- 鉴定作业台
- 用户端订单详情
- 报告详情页的商品摘要 / 估值摘要一致性
目标:
- 页面展示与接口字段保持一致
- 同一份订单数据在用户端、后台、报告端口径一致
### P2把后端静态内容迁移为可维护内容
优先处理:
- 首页内容
- 帮助中心
- 设置页协议入口
- 下单预览协议区
推荐做法:
- 新建内容配置表 / 帮助中心表 / 协议配置表
- 后台增加内容维护入口
- 用户端改为读取接口配置
### P3把前端硬编码枚举逐步改为字典化
优先处理:
- 工单类型
- 鉴定结果模板
- 用户端下单辅助枚举
## 七、建议的下一步落地动作
1. 先移除用户端业务页面对 `user-app/src/mocks/app.ts` 的依赖,改为错误态 + 重试。
2. 统一订单 / 报告 / 鉴定任务三端字段口径,补齐 `color``condition_desc``remark``accessories` 等实际字段。
3. 设计内容配置模型,把首页、帮助中心、协议说明迁到后台管理。
4. 最后再做字典化,把工单类型、辅助选项、结果模板迁到接口配置。

BIN
logo.jpeg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 380 KiB

17
server-api/.env.example Normal file
View File

@@ -0,0 +1,17 @@
APP_ENV=production
APP_DEBUG=false
PUBLIC_FILE_BASE_URL=
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=anxinyan
DB_USERNAME=root
DB_PASSWORD=change_me
DB_CHARSET=utf8mb4
DB_PREFIX=
REDIS_HOST=127.0.0.1
REDIS_PORT=6379
REDIS_PASSWORD=
REDIS_DB=0
REDIS_PREFIX=anxinyan:

8
server-api/.gitignore vendored Normal file
View File

@@ -0,0 +1,8 @@
/runtime
/.idea
/.vscode
/vendor
*.log
.env
/tests/tmp
/tests/.phpunit.result.cache

18
server-api/Dockerfile Normal file
View File

@@ -0,0 +1,18 @@
FROM php:8.3.22-cli-alpine
RUN mv "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini"
RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories \
&& apk update --no-cache \
&& docker-php-source extract
# install extensions
RUN docker-php-ext-install pdo pdo_mysql -j$(nproc) pcntl
# enable opcache and pcntl
RUN docker-php-ext-enable opcache pcntl
RUN docker-php-source delete \
rm -rf /var/cache/apk/*
RUN mkdir -p /app
WORKDIR /app

21
server-api/LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2021 walkor<walkor@workerman.net> and contributors (see https://github.com/walkor/webman/contributors)
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

70
server-api/README.md Normal file
View File

@@ -0,0 +1,70 @@
<div style="padding:18px;max-width: 1024px;margin:0 auto;background-color:#fff;color:#333">
<h1>webman</h1>
基于<a href="https://www.workerman.net" target="__blank">workerman</a>开发的超高性能PHP框架
<h1>学习</h1>
<ul>
<li>
<a href="https://www.workerman.net/webman" target="__blank">主页 / Home page</a>
</li>
<li>
<a href="https://webman.workerman.net" target="__blank">文档 / Document</a>
</li>
<li>
<a href="https://www.workerman.net/doc/webman/install.html" target="__blank">安装 / Install</a>
</li>
<li>
<a href="https://www.workerman.net/questions" target="__blank">问答 / Questions</a>
</li>
<li>
<a href="https://www.workerman.net/apps" target="__blank">市场 / Apps</a>
</li>
<li>
<a href="https://www.workerman.net/sponsor" target="__blank">赞助 / Sponsors</a>
</li>
<li>
<a href="https://www.workerman.net/doc/webman/thanks.html" target="__blank">致谢 / Thanks</a>
</li>
</ul>
<div style="float:left;padding-bottom:30px;">
<h1>赞助商</h1>
<h4>特别赞助</h4>
<a href="https://www.crmeb.com/?form=workerman" target="__blank">
<img src="https://www.workerman.net/img/sponsors/6429/20230719111500.svg" width="200">
</a>
<h4>铂金赞助</h4>
<a href="https://www.fadetask.com/?from=workerman" target="__blank"><img src="https://www.workerman.net/img/sponsors/1/20230719084316.png" width="200"></a>
<a href="https://www.yilianyun.net/?from=workerman" target="__blank" style="margin-left:20px;"><img src="https://www.workerman.net/img/sponsors/6218/20230720114049.png" width="200"></a>
</div>
<div style="float:left;padding-bottom:30px;clear:both">
<h1>请作者喝咖啡</h1>
<img src="https://www.workerman.net/img/wx_donate.png" width="200">
<img src="https://www.workerman.net/img/ali_donate.png" width="200">
<br>
<b>如果您觉得webman对您有所帮助欢迎捐赠。</b>
</div>
<div style="clear: both">
<h1>LICENSE</h1>
The webman is open-sourced software licensed under the MIT.
</div>
</div>

View File

@@ -0,0 +1,22 @@
<?php
namespace app\bootstrap;
use Dotenv\Dotenv as DotenvLoader;
use Webman\Bootstrap;
use Workerman\Worker;
class Dotenv implements Bootstrap
{
private static bool $loaded = false;
public static function start(?Worker $worker): void
{
if (self::$loaded || !is_file(base_path('.env'))) {
return;
}
DotenvLoader::createImmutable(base_path())->safeLoad();
self::$loaded = true;
}
}

View File

@@ -0,0 +1,42 @@
<?php
namespace app\controller;
use support\Request;
class IndexController
{
public function index(Request $request)
{
return <<<EOF
<style>
* {
padding: 0;
margin: 0;
}
iframe {
border: none;
overflow: scroll;
}
</style>
<iframe
src="https://www.workerman.net/wellcome"
width="100%"
height="100%"
allow="clipboard-write"
sandbox="allow-scripts allow-same-origin allow-popups allow-downloads"
></iframe>
EOF;
}
public function view(Request $request)
{
return view('index/view', ['name' => 'webman']);
}
public function json(Request $request)
{
return json(['code' => 0, 'msg' => 'ok']);
}
}

View File

@@ -0,0 +1,296 @@
<?php
namespace app\controller\admin;
use app\support\AdminAccessService;
use support\Request;
use support\think\Db;
class AccessController
{
public function overview(Request $request)
{
$this->accessService()->bootstrapDefaults();
return api_success([
'cards' => [
[
'title' => '管理员数量',
'value' => (int)Db::name('admin_users')->count(),
'desc' => '当前后台管理员账号总数',
],
[
'title' => '启用角色',
'value' => (int)Db::name('admin_roles')->where('status', 'enabled')->count(),
'desc' => '当前启用中的角色数量',
],
[
'title' => '权限点',
'value' => (int)Db::name('admin_permissions')->count(),
'desc' => '后台模块当前可分配的权限点数量',
],
[
'title' => '角色授权',
'value' => (int)Db::name('admin_role_permissions')->count(),
'desc' => '角色与权限的关联配置总数',
],
],
]);
}
public function admins(Request $request)
{
$this->accessService()->bootstrapDefaults();
$rows = Db::name('admin_users')
->order('id', 'desc')
->select()
->toArray();
$list = array_map(function (array $item) {
$roleIds = Db::name('admin_role_relations')->where('admin_user_id', $item['id'])->column('role_id');
$roles = $roleIds
? Db::name('admin_roles')->whereIn('id', $roleIds)->column('name')
: [];
return [
'id' => (int)$item['id'],
'name' => $item['name'],
'mobile' => $item['mobile'],
'email' => $item['email'],
'status' => $item['status'],
'status_text' => $this->accessService()->statusText($item['status']),
'role_ids' => array_map('intval', $roleIds),
'role_names' => array_values($roles),
'last_login_at' => $item['last_login_at'],
'created_at' => $item['created_at'],
];
}, $rows);
return api_success(['list' => $list]);
}
public function roles(Request $request)
{
$this->accessService()->bootstrapDefaults();
$rows = Db::name('admin_roles')
->order('id', 'asc')
->select()
->toArray();
$list = array_map(function (array $item) {
$permissionIds = Db::name('admin_role_permissions')->where('role_id', $item['id'])->column('permission_id');
$permissions = $permissionIds
? Db::name('admin_permissions')->whereIn('id', $permissionIds)->column('name')
: [];
return [
'id' => (int)$item['id'],
'name' => $item['name'],
'code' => $item['code'],
'status' => $item['status'],
'status_text' => $this->accessService()->statusText($item['status']),
'permission_ids' => array_map('intval', $permissionIds),
'permission_names' => array_values($permissions),
'admin_count' => (int)Db::name('admin_role_relations')->where('role_id', $item['id'])->count(),
'created_at' => $item['created_at'],
];
}, $rows);
return api_success(['list' => $list]);
}
public function permissions(Request $request)
{
$this->accessService()->bootstrapDefaults();
$rows = Db::name('admin_permissions')
->order('module', 'asc')
->order('id', 'asc')
->select()
->toArray();
return api_success([
'list' => array_map(fn (array $item) => [
'id' => (int)$item['id'],
'name' => $item['name'],
'code' => $item['code'],
'module' => $item['module'],
'action' => $item['action'],
'module_text' => $this->accessService()->moduleText($item['module']),
], $rows),
]);
}
public function saveAdmin(Request $request)
{
$this->accessService()->bootstrapDefaults();
$id = (int)$request->input('id', 0);
$name = trim((string)$request->input('name', ''));
$mobile = trim((string)$request->input('mobile', ''));
$email = trim((string)$request->input('email', ''));
$password = trim((string)$request->input('password', ''));
$status = trim((string)$request->input('status', 'enabled'));
$roleIds = $this->normalizeIds((array)$request->input('role_ids', []));
if ($name === '' || $mobile === '') {
return api_error('管理员姓名和手机号不能为空', 422);
}
$now = date('Y-m-d H:i:s');
Db::startTrans();
try {
if ($id > 0) {
$admin = Db::name('admin_users')->where('id', $id)->find();
if (!$admin) {
Db::rollback();
return api_error('管理员不存在', 404);
}
$exists = Db::name('admin_users')
->where('mobile', $mobile)
->where('id', '<>', $id)
->find();
if ($exists) {
Db::rollback();
return api_error('管理员手机号已存在', 422);
}
Db::name('admin_users')->where('id', $id)->update([
'name' => $name,
'mobile' => $mobile,
'email' => $email,
'password' => $password !== '' ? password_hash($password, PASSWORD_BCRYPT) : $admin['password'],
'status' => $status !== '' ? $status : 'enabled',
'updated_at' => $now,
]);
$adminId = $id;
} else {
$exists = Db::name('admin_users')->where('mobile', $mobile)->find();
if ($exists) {
Db::rollback();
return api_error('管理员手机号已存在', 422);
}
$adminId = (int)Db::name('admin_users')->insertGetId([
'name' => $name,
'mobile' => $mobile,
'email' => $email,
'password' => password_hash($password !== '' ? $password : 'Admin@123456', PASSWORD_BCRYPT),
'status' => $status !== '' ? $status : 'enabled',
'last_login_at' => null,
'created_at' => $now,
'updated_at' => $now,
]);
}
Db::name('admin_role_relations')->where('admin_user_id', $adminId)->delete();
foreach ($roleIds as $roleId) {
Db::name('admin_role_relations')->insert([
'admin_user_id' => $adminId,
'role_id' => $roleId,
'created_at' => $now,
]);
}
Db::commit();
} catch (\Throwable $e) {
Db::rollback();
return api_error('管理员保存失败', 500, [
'detail' => $e->getMessage(),
]);
}
return api_success(['id' => $adminId], '管理员已保存');
}
public function saveRole(Request $request)
{
$this->accessService()->bootstrapDefaults();
$id = (int)$request->input('id', 0);
$name = trim((string)$request->input('name', ''));
$code = trim((string)$request->input('code', ''));
$status = trim((string)$request->input('status', 'enabled'));
$permissionIds = $this->normalizeIds((array)$request->input('permission_ids', []));
if ($name === '' || $code === '') {
return api_error('角色名称和编码不能为空', 422);
}
$now = date('Y-m-d H:i:s');
Db::startTrans();
try {
if ($id > 0) {
$role = Db::name('admin_roles')->where('id', $id)->find();
if (!$role) {
Db::rollback();
return api_error('角色不存在', 404);
}
$exists = Db::name('admin_roles')
->where('code', $code)
->where('id', '<>', $id)
->find();
if ($exists) {
Db::rollback();
return api_error('角色编码已存在', 422);
}
Db::name('admin_roles')->where('id', $id)->update([
'name' => $name,
'code' => $code,
'status' => $status !== '' ? $status : 'enabled',
'updated_at' => $now,
]);
$roleId = $id;
} else {
$exists = Db::name('admin_roles')->where('code', $code)->find();
if ($exists) {
Db::rollback();
return api_error('角色编码已存在', 422);
}
$roleId = (int)Db::name('admin_roles')->insertGetId([
'name' => $name,
'code' => $code,
'status' => $status !== '' ? $status : 'enabled',
'created_at' => $now,
'updated_at' => $now,
]);
}
Db::name('admin_role_permissions')->where('role_id', $roleId)->delete();
foreach ($permissionIds as $permissionId) {
Db::name('admin_role_permissions')->insert([
'role_id' => $roleId,
'permission_id' => $permissionId,
'created_at' => $now,
]);
}
Db::commit();
} catch (\Throwable $e) {
Db::rollback();
return api_error('角色保存失败', 500, [
'detail' => $e->getMessage(),
]);
}
return api_success(['id' => $roleId], '角色已保存');
}
private function normalizeIds(array $values): array
{
return array_values(array_unique(array_filter(array_map('intval', $values), fn (int $item) => $item > 0)));
}
private function accessService(): AdminAccessService
{
return new AdminAccessService();
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,44 @@
<?php
namespace app\controller\admin;
use app\support\AdminAuthService;
use support\Request;
class AuthController
{
public function login(Request $request)
{
$mobile = trim((string)$request->input('mobile', ''));
$password = trim((string)$request->input('password', ''));
if ($mobile === '' || $password === '') {
return api_error('手机号和密码不能为空', 422);
}
try {
$payload = (new AdminAuthService())->login($mobile, $password, $request);
return api_success($payload, '登录成功');
} catch (\Throwable $e) {
return api_error($e->getMessage(), 401);
}
}
public function me(Request $request)
{
$admin = (new AdminAuthService())->current($request);
if (!$admin) {
return api_error('未登录或登录已过期', 401);
}
return api_success([
'admin_info' => $admin,
]);
}
public function logout(Request $request)
{
(new AdminAuthService())->logout($request);
return api_success([], '已退出登录');
}
}

View File

@@ -0,0 +1,889 @@
<?php
namespace app\controller\admin;
use app\support\CatalogTemplateSampleImageService;
use app\support\ContentService;
use app\support\FileStorageService;
use support\Request;
use support\think\Db;
class CatalogController
{
public function overview(Request $request)
{
return api_success([
'cards' => [
[
'title' => '启用品类',
'value' => (int)Db::name('catalog_categories')->where('is_enabled', 1)->count(),
'desc' => '当前前台可用的鉴定品类数量',
],
[
'title' => '启用品牌',
'value' => (int)Db::name('catalog_brands')->where('is_enabled', 1)->count(),
'desc' => '已配置并启用的品牌数量',
],
],
]);
}
public function categories(Request $request)
{
$rows = Db::name('catalog_categories')
->field([
'id',
'name',
'code',
'sort_order',
'is_enabled',
'need_shipping',
'supported_service_types',
])
->order('sort_order', 'asc')
->select()
->toArray();
$categoryVisuals = $this->categoryVisualMap($request);
$templateSummaryMap = [];
$appraisalTemplateSummaryMap = [];
if ($rows) {
$categoryIds = array_map(fn (array $item) => (int)$item['id'], $rows);
$templateRows = Db::name('upload_templates')
->field(['id', 'scope_id'])
->where('scope_type', 'category')
->whereIn('scope_id', $categoryIds)
->where('is_enabled', 1)
->select()
->toArray();
$templateIds = array_map(fn (array $item) => (int)$item['id'], $templateRows);
$itemCountMap = [];
if ($templateIds) {
$itemRows = Db::name('upload_template_items')
->fieldRaw('template_id, COUNT(*) AS item_count')
->whereIn('template_id', $templateIds)
->where('is_enabled', 1)
->group('template_id')
->select()
->toArray();
foreach ($itemRows as $item) {
$itemCountMap[(int)$item['template_id']] = (int)$item['item_count'];
}
}
foreach ($templateRows as $item) {
$categoryId = (int)($item['scope_id'] ?? 0);
if ($categoryId <= 0) {
continue;
}
if (!isset($templateSummaryMap[$categoryId])) {
$templateSummaryMap[$categoryId] = [
'template_count' => 0,
'item_count' => 0,
];
}
$templateSummaryMap[$categoryId]['template_count'] += 1;
$templateSummaryMap[$categoryId]['item_count'] += $itemCountMap[(int)$item['id']] ?? 0;
}
$appraisalTemplateRows = Db::name('appraisal_templates')
->field(['id', 'scope_id', 'is_default'])
->where('scope_type', 'category')
->whereIn('scope_id', $categoryIds)
->where('is_enabled', 1)
->order('is_default', 'desc')
->order('id', 'desc')
->select()
->toArray();
$appraisalTemplateIds = array_map(fn (array $item) => (int)$item['id'], $appraisalTemplateRows);
$pointCountMap = [];
if ($appraisalTemplateIds) {
$pointRows = Db::name('appraisal_template_key_points')
->fieldRaw('template_id, COUNT(*) AS point_count')
->whereIn('template_id', $appraisalTemplateIds)
->group('template_id')
->select()
->toArray();
foreach ($pointRows as $item) {
$pointCountMap[(int)$item['template_id']] = (int)$item['point_count'];
}
}
foreach ($appraisalTemplateRows as $item) {
$categoryId = (int)($item['scope_id'] ?? 0);
if ($categoryId <= 0) {
continue;
}
if (isset($appraisalTemplateSummaryMap[$categoryId])) {
continue;
}
if (!isset($appraisalTemplateSummaryMap[$categoryId])) {
$appraisalTemplateSummaryMap[$categoryId] = [
'template_count' => 0,
'point_count' => 0,
];
}
$appraisalTemplateSummaryMap[$categoryId]['template_count'] = 1;
$appraisalTemplateSummaryMap[$categoryId]['point_count'] += $pointCountMap[(int)$item['id']] ?? 0;
}
}
$list = array_map(function (array $item) use ($templateSummaryMap, $appraisalTemplateSummaryMap, $categoryVisuals) {
$summary = $templateSummaryMap[(int)$item['id']] ?? ['template_count' => 0, 'item_count' => 0];
$appraisalSummary = $appraisalTemplateSummaryMap[(int)$item['id']] ?? ['template_count' => 0, 'point_count' => 0];
$codeKey = $this->categoryMatchKey((string)$item['code']);
$nameKey = $this->categoryMatchKey((string)$item['name']);
return [
'id' => (int)$item['id'],
'name' => $item['name'],
'code' => $item['code'],
'image_url' => $categoryVisuals['code:' . $codeKey] ?? $categoryVisuals['name:' . $nameKey] ?? '',
'sort_order' => (int)$item['sort_order'],
'is_enabled' => (bool)$item['is_enabled'],
'need_shipping' => (bool)$item['need_shipping'],
'supported_service_types' => $this->decodeJsonArray($item['supported_service_types'] ?? null),
'upload_template_count' => (int)$summary['template_count'],
'upload_template_item_count' => (int)$summary['item_count'],
'upload_template_summary' => (int)$summary['template_count'] > 0
? sprintf('%d 套模板 / %d 项采集项', (int)$summary['template_count'], (int)$summary['item_count'])
: '未配置模板',
'appraisal_template_count' => 1,
'appraisal_template_point_count' => (int)$appraisalSummary['point_count'],
'appraisal_template_summary' => sprintf('%d 个自定义鉴定项', (int)$appraisalSummary['point_count']),
];
}, $rows);
return api_success(['list' => $list]);
}
public function uploadTemplates(Request $request)
{
$categoryId = (int)$request->input('category_id', 0);
if ($categoryId <= 0) {
return api_error('品类 ID 不能为空', 422);
}
$category = Db::name('catalog_categories')->where('id', $categoryId)->find();
if (!$category) {
return api_error('品类不存在', 404);
}
$serviceProviders = $this->decodeJsonArray($category['supported_service_types'] ?? null);
if (!$serviceProviders) {
$serviceProviders = ['anxinyan'];
}
$existingRows = Db::name('upload_templates')
->where('scope_type', 'category')
->where('scope_id', $categoryId)
->whereIn('service_provider', $serviceProviders)
->order('id', 'desc')
->select()
->toArray();
$existingMap = [];
foreach ($existingRows as $row) {
$provider = (string)($row['service_provider'] ?? '');
if ($provider === '' || isset($existingMap[$provider])) {
continue;
}
$existingMap[$provider] = $row;
}
$list = array_map(function (string $serviceProvider) use ($categoryId, $category, $existingMap, $request) {
$existing = $existingMap[$serviceProvider] ?? null;
$items = [];
if ($existing) {
$itemRows = Db::name('upload_template_items')
->where('template_id', (int)$existing['id'])
->order('sort_order', 'asc')
->order('id', 'asc')
->select()
->toArray();
$items = array_map(fn (array $item) => [
'id' => (int)$item['id'],
'item_code' => (string)$item['item_code'],
'item_name' => (string)$item['item_name'],
'is_required' => (bool)$item['is_required'],
'guide_text' => (string)$item['guide_text'],
'sample_image_url' => $this->templateSampleImageService()->normalizeUrl((string)$item['sample_image_url'], $request),
'max_upload_count' => (int)$item['max_upload_count'],
'sort_order' => (int)$item['sort_order'],
'is_enabled' => (bool)$item['is_enabled'],
], $itemRows);
}
return [
'id' => $existing ? (int)$existing['id'] : null,
'category_id' => $categoryId,
'category_name' => (string)$category['name'],
'service_provider' => $serviceProvider,
'service_provider_text' => $this->serviceProviderText($serviceProvider),
'name' => $existing['name'] ?? sprintf('%s-%s上传模板', (string)$category['name'], $this->serviceProviderText($serviceProvider)),
'code' => $existing['code'] ?? sprintf('upload_category_%d_%s', $categoryId, $serviceProvider),
'is_enabled' => $existing ? (bool)$existing['is_enabled'] : true,
'is_default' => $existing ? (bool)$existing['is_default'] : ($serviceProvider === 'anxinyan'),
'items' => $items,
];
}, $serviceProviders);
return api_success([
'category' => [
'id' => $categoryId,
'name' => (string)$category['name'],
'code' => (string)$category['code'],
],
'list' => $list,
]);
}
public function saveUploadTemplates(Request $request)
{
$categoryId = (int)$request->input('category_id', 0);
$templates = $request->input('templates', []);
if ($categoryId <= 0) {
return api_error('品类 ID 不能为空', 422);
}
if (!is_array($templates)) {
return api_error('模板数据格式不正确', 422);
}
$category = Db::name('catalog_categories')->where('id', $categoryId)->find();
if (!$category) {
return api_error('品类不存在', 404);
}
$serviceProviders = $this->decodeJsonArray($category['supported_service_types'] ?? null);
if (!$serviceProviders) {
$serviceProviders = ['anxinyan'];
}
$allowedProviders = array_fill_keys($serviceProviders, true);
$now = date('Y-m-d H:i:s');
$defaultTemplateId = 0;
$orphanSampleImageUrls = [];
Db::startTrans();
try {
foreach ($templates as $template) {
if (!is_array($template)) {
continue;
}
$serviceProvider = trim((string)($template['service_provider'] ?? ''));
if ($serviceProvider === '' || !isset($allowedProviders[$serviceProvider])) {
continue;
}
$templateId = (int)($template['id'] ?? 0);
$exists = null;
if ($templateId > 0) {
$exists = Db::name('upload_templates')->where('id', $templateId)->find();
}
if (!$exists) {
$exists = Db::name('upload_templates')
->where('scope_type', 'category')
->where('scope_id', $categoryId)
->where('service_provider', $serviceProvider)
->order('id', 'desc')
->find();
}
$payload = [
'name' => trim((string)($template['name'] ?? '')) ?: sprintf('%s-%s上传模板', (string)$category['name'], $this->serviceProviderText($serviceProvider)),
'code' => trim((string)($template['code'] ?? '')) ?: sprintf('upload_category_%d_%s', $categoryId, $serviceProvider),
'scope_type' => 'category',
'scope_id' => $categoryId,
'service_provider' => $serviceProvider,
'is_default' => !empty($template['is_default']) ? 1 : 0,
'is_enabled' => array_key_exists('is_enabled', $template) ? (!empty($template['is_enabled']) ? 1 : 0) : 1,
'updated_at' => $now,
];
if ($exists) {
Db::name('upload_templates')->where('id', (int)$exists['id'])->update($payload);
$savedTemplateId = (int)$exists['id'];
} else {
$payload['created_at'] = $now;
$savedTemplateId = (int)Db::name('upload_templates')->insertGetId($payload);
}
if ($serviceProvider === 'anxinyan') {
$defaultTemplateId = $savedTemplateId;
}
$existingItemRows = Db::name('upload_template_items')
->where('template_id', $savedTemplateId)
->select()
->toArray();
$existingSampleUrls = array_values(array_filter(array_map(
fn (array $item) => $this->templateSampleImageService()->storagePath((string)($item['sample_image_url'] ?? '')),
$existingItemRows
)));
Db::name('upload_template_items')->where('template_id', $savedTemplateId)->delete();
$items = is_array($template['items'] ?? null) ? $template['items'] : [];
$insertRows = [];
$nextSampleUrls = [];
foreach ($items as $index => $item) {
if (!is_array($item)) {
continue;
}
$itemCode = trim((string)($item['item_code'] ?? ''));
$itemName = trim((string)($item['item_name'] ?? ''));
if ($itemCode === '' || $itemName === '') {
continue;
}
$insertRows[] = [
'template_id' => $savedTemplateId,
'item_code' => $itemCode,
'item_name' => $itemName,
'is_required' => !empty($item['is_required']) ? 1 : 0,
'guide_text' => trim((string)($item['guide_text'] ?? '')),
'sample_image_url' => $this->templateSampleImageService()->storagePath((string)($item['sample_image_url'] ?? '')),
'max_upload_count' => max(1, (int)($item['max_upload_count'] ?? 1)),
'sort_order' => (int)($item['sort_order'] ?? (($index + 1) * 10)),
'is_enabled' => array_key_exists('is_enabled', $item) ? (!empty($item['is_enabled']) ? 1 : 0) : 1,
'created_at' => $now,
'updated_at' => $now,
];
$sampleUrl = $this->templateSampleImageService()->storagePath((string)($item['sample_image_url'] ?? ''));
if ($sampleUrl !== '') {
$nextSampleUrls[] = $sampleUrl;
}
}
if ($insertRows) {
Db::name('upload_template_items')->insertAll($insertRows);
}
$removedSampleUrls = array_values(array_diff($existingSampleUrls, $nextSampleUrls));
if ($removedSampleUrls) {
$orphanSampleImageUrls = array_values(array_unique(array_merge($orphanSampleImageUrls, $removedSampleUrls)));
}
}
Db::name('catalog_categories')->where('id', $categoryId)->update([
'default_upload_template_id' => $defaultTemplateId > 0 ? $defaultTemplateId : null,
'updated_at' => $now,
]);
Db::commit();
} catch (\Throwable $e) {
Db::rollback();
return api_error('上传模板保存失败', 500, [
'detail' => $e->getMessage(),
]);
}
foreach ($orphanSampleImageUrls as $fileUrl) {
$this->templateSampleImageService()->delete($fileUrl);
}
return api_success([
'category_id' => $categoryId,
], '上传模板已保存');
}
public function appraisalTemplates(Request $request)
{
$categoryId = (int)$request->input('category_id', 0);
if ($categoryId <= 0) {
return api_error('品类 ID 不能为空', 422);
}
$category = Db::name('catalog_categories')->where('id', $categoryId)->find();
if (!$category) {
return api_error('品类不存在', 404);
}
$template = Db::name('appraisal_templates')
->where('scope_type', 'category')
->where('scope_id', $categoryId)
->where('is_enabled', 1)
->order('is_default', 'desc')
->order('id', 'desc')
->find();
$points = [];
if ($template) {
$pointRows = Db::name('appraisal_template_key_points')
->where('template_id', (int)$template['id'])
->order('sort_order', 'asc')
->order('id', 'asc')
->select()
->toArray();
$points = array_map(fn (array $item) => [
'id' => (int)$item['id'],
'point_code' => (string)$item['point_code'],
'point_name' => (string)$item['point_name'],
'point_type' => (string)$item['point_type'],
'options' => $this->decodeJsonArray($item['options_json'] ?? null),
'sort_order' => (int)$item['sort_order'],
'is_required' => (bool)$item['is_required'],
], $pointRows);
}
$payload = [
'id' => $template ? (int)$template['id'] : null,
'category_id' => $categoryId,
'category_name' => (string)$category['name'],
'service_provider' => 'category',
'service_provider_text' => '通用品类模板',
'name' => $template['name'] ?? sprintf('%s鉴定模板', (string)$category['name']),
'code' => $template['code'] ?? sprintf('appraisal_category_%d', $categoryId),
'is_enabled' => true,
'is_default' => true,
'result_options' => [],
'condition_options' => [],
'valuation_hint' => '',
'key_points' => $points,
];
return api_success([
'category' => [
'id' => $categoryId,
'name' => (string)$category['name'],
'code' => (string)$category['code'],
],
'template' => $payload,
'list' => [$payload],
]);
}
public function saveAppraisalTemplates(Request $request)
{
$categoryId = (int)$request->input('category_id', 0);
$template = $request->input('template', null);
$templates = $request->input('templates', []);
if ($categoryId <= 0) {
return api_error('品类 ID 不能为空', 422);
}
if (!is_array($template)) {
$template = is_array($templates) ? ($templates[0] ?? []) : [];
}
if (!is_array($template)) {
return api_error('模板数据格式不正确', 422);
}
$category = Db::name('catalog_categories')->where('id', $categoryId)->find();
if (!$category) {
return api_error('品类不存在', 404);
}
$now = date('Y-m-d H:i:s');
Db::startTrans();
try {
$exists = Db::name('appraisal_templates')
->where('scope_type', 'category')
->where('scope_id', $categoryId)
->order('is_default', 'desc')
->order('id', 'desc')
->find();
$payload = [
'name' => sprintf('%s鉴定模板', (string)$category['name']),
'code' => sprintf('appraisal_category_%d', $categoryId),
'scope_type' => 'category',
'scope_id' => $categoryId,
'service_provider' => 'category',
'is_default' => 1,
'is_enabled' => 1,
'result_options_json' => json_encode([], JSON_UNESCAPED_UNICODE),
'condition_rule_json' => json_encode([], JSON_UNESCAPED_UNICODE),
'valuation_rule_json' => json_encode([], JSON_UNESCAPED_UNICODE),
'updated_at' => $now,
];
if ($exists) {
Db::name('appraisal_templates')->where('id', (int)$exists['id'])->update($payload);
$savedTemplateId = (int)$exists['id'];
} else {
$payload['created_at'] = $now;
$savedTemplateId = (int)Db::name('appraisal_templates')->insertGetId($payload);
}
$otherTemplateIds = Db::name('appraisal_templates')
->where('scope_type', 'category')
->where('scope_id', $categoryId)
->where('id', '<>', $savedTemplateId)
->column('id');
if ($otherTemplateIds) {
Db::name('appraisal_templates')->whereIn('id', $otherTemplateIds)->update([
'is_enabled' => 0,
'is_default' => 0,
'updated_at' => $now,
]);
}
Db::name('appraisal_template_key_points')->where('template_id', $savedTemplateId)->delete();
$points = is_array($template['key_points'] ?? null) ? $template['key_points'] : [];
$insertRows = [];
foreach ($points as $index => $point) {
if (!is_array($point)) {
continue;
}
$pointName = trim((string)($point['point_name'] ?? ''));
if ($pointName === '') {
continue;
}
$pointCode = $this->normalizeCode((string)($point['point_code'] ?? '')) ?: sprintf('point_%d', $index + 1);
$pointType = trim((string)($point['point_type'] ?? 'text'));
if (!in_array($pointType, ['text', 'textarea', 'select', 'boolean'], true)) {
$pointType = 'text';
}
$insertRows[] = [
'template_id' => $savedTemplateId,
'point_code' => $pointCode,
'point_name' => $pointName,
'point_type' => $pointType,
'options_json' => json_encode($this->normalizeArray($point['options'] ?? []), JSON_UNESCAPED_UNICODE),
'sort_order' => (int)($point['sort_order'] ?? (($index + 1) * 10)),
'is_required' => !empty($point['is_required']) ? 1 : 0,
'created_at' => $now,
'updated_at' => $now,
];
}
if ($insertRows) {
Db::name('appraisal_template_key_points')->insertAll($insertRows);
}
Db::name('catalog_categories')->where('id', $categoryId)->update([
'default_appraisal_template_id' => $savedTemplateId,
'updated_at' => $now,
]);
Db::commit();
} catch (\Throwable $e) {
Db::rollback();
return api_error('鉴定模板保存失败', 500, [
'detail' => $e->getMessage(),
]);
}
return api_success([
'category_id' => $categoryId,
], '鉴定模板已保存');
}
public function uploadTemplateSampleImage(Request $request)
{
try {
$asset = $this->templateSampleImageService()->upload($request);
return api_success($asset, '示例图上传成功');
} catch (\Throwable $e) {
return api_error($e->getMessage(), 422);
}
}
public function deleteUploadTemplateSampleImage(Request $request)
{
$fileUrl = trim((string)$request->input('file_url', ''));
if ($fileUrl === '') {
return api_error('文件地址不能为空', 422);
}
$this->templateSampleImageService()->delete($fileUrl);
return api_success([
'file_url' => $fileUrl,
], '示例图已删除');
}
public function saveCategory(Request $request)
{
$id = (int)$request->input('id', 0);
$name = trim((string)$request->input('name', ''));
$code = trim((string)$request->input('code', ''));
$imageUrl = trim((string)$request->input('image_url', ''));
if ($name === '' || $code === '') {
return api_error('品类名称和编码不能为空', 422);
}
$previous = $id > 0
? Db::name('catalog_categories')->field(['name', 'code'])->where('id', $id)->find()
: null;
$payload = [
'name' => $name,
'code' => $code,
'sort_order' => (int)$request->input('sort_order', 0),
'is_enabled' => $request->input('is_enabled', true) ? 1 : 0,
'need_shipping' => $request->input('need_shipping', true) ? 1 : 0,
'supported_service_types' => json_encode($this->normalizeArray($request->input('supported_service_types', [])), JSON_UNESCAPED_UNICODE),
'updated_at' => date('Y-m-d H:i:s'),
];
if ($id > 0) {
Db::name('catalog_categories')->where('id', $id)->update($payload);
$this->saveCategoryVisual($name, $code, $imageUrl, is_array($previous) ? $previous : null);
return api_success(['id' => $id], '更新成功');
}
$payload['created_at'] = date('Y-m-d H:i:s');
$newId = Db::name('catalog_categories')->insertGetId($payload);
$this->saveCategoryVisual($name, $code, $imageUrl);
return api_success(['id' => (int)$newId], '创建成功');
}
public function brands(Request $request)
{
$rows = Db::name('catalog_brands')
->alias('b')
->leftJoin('catalog_brand_categories cbc', 'cbc.brand_id = b.id')
->leftJoin('catalog_categories c', 'c.id = cbc.category_id')
->field([
'b.id',
'b.name',
'b.en_name',
'b.code',
'b.sort_order',
'b.is_enabled',
'b.supported_service_types',
'GROUP_CONCAT(DISTINCT cbc.category_id) AS category_ids',
'GROUP_CONCAT(DISTINCT c.name) AS category_names',
])
->group('b.id')
->order('b.sort_order', 'asc')
->select()
->toArray();
$list = array_map(function (array $item) {
return [
'id' => (int)$item['id'],
'name' => $item['name'],
'en_name' => $item['en_name'],
'code' => $item['code'],
'sort_order' => (int)$item['sort_order'],
'is_enabled' => (bool)$item['is_enabled'],
'category_ids' => $this->decodeIntList($item['category_ids'] ?? ''),
'category_names' => $item['category_names'] ?: '',
'supported_service_types' => $this->decodeJsonArray($item['supported_service_types'] ?? null),
];
}, $rows);
return api_success(['list' => $list]);
}
public function saveBrand(Request $request)
{
$id = (int)$request->input('id', 0);
$name = trim((string)$request->input('name', ''));
$enName = trim((string)$request->input('en_name', ''));
$code = trim((string)$request->input('code', ''));
$categoryIds = $this->normalizeIntArray($request->input('category_ids', []));
if ($name === '' || $code === '') {
return api_error('品牌名称和编码不能为空', 422);
}
$payload = [
'name' => $name,
'en_name' => $enName,
'code' => $code,
'sort_order' => (int)$request->input('sort_order', 0),
'is_enabled' => $request->input('is_enabled', true) ? 1 : 0,
'supported_service_types' => json_encode($this->normalizeArray($request->input('supported_service_types', [])), JSON_UNESCAPED_UNICODE),
'updated_at' => date('Y-m-d H:i:s'),
];
Db::startTrans();
try {
if ($id > 0) {
Db::name('catalog_brands')->where('id', $id)->update($payload);
Db::name('catalog_brand_categories')->where('brand_id', $id)->delete();
foreach ($categoryIds as $categoryId) {
Db::name('catalog_brand_categories')->insert([
'brand_id' => $id,
'category_id' => $categoryId,
'created_at' => date('Y-m-d H:i:s'),
]);
}
Db::commit();
return api_success(['id' => $id], '更新成功');
}
$payload['logo'] = '';
$payload['created_at'] = date('Y-m-d H:i:s');
$newId = Db::name('catalog_brands')->insertGetId($payload);
foreach ($categoryIds as $categoryId) {
Db::name('catalog_brand_categories')->insert([
'brand_id' => $newId,
'category_id' => $categoryId,
'created_at' => date('Y-m-d H:i:s'),
]);
}
Db::commit();
return api_success(['id' => (int)$newId], '创建成功');
} catch (\Throwable $e) {
Db::rollback();
return api_error('品牌保存失败', 500, [
'detail' => $e->getMessage(),
]);
}
}
private function decodeJsonArray(mixed $value): array
{
if (is_array($value)) {
return array_values($value);
}
if (is_string($value) && $value !== '') {
$decoded = json_decode($value, true);
return is_array($decoded) ? array_values($decoded) : [];
}
return [];
}
private function decodeJsonObject(mixed $value): array
{
if (is_array($value)) {
return $value;
}
if (is_string($value) && $value !== '') {
$decoded = json_decode($value, true);
return is_array($decoded) ? $decoded : [];
}
return [];
}
private function normalizeArray(mixed $value): array
{
if (is_array($value)) {
return array_values(array_filter(array_map(static fn ($item) => trim((string)$item), $value), static fn ($item) => $item !== ''));
}
if (is_string($value) && $value !== '') {
return array_values(array_filter(array_map('trim', explode(',', $value)), static fn ($item) => $item !== ''));
}
return [];
}
private function normalizeCode(string $value): string
{
$normalized = strtolower(trim($value));
$normalized = (string)preg_replace('/[^a-z0-9_]+/', '_', $normalized);
$normalized = trim($normalized, '_');
return $normalized;
}
private function normalizeIntArray(mixed $value): array
{
if (!is_array($value)) {
return [];
}
return array_values(array_filter(array_map(static fn ($item) => (int)$item, $value), static fn ($item) => $item > 0));
}
private function decodeIntList(string $value): array
{
if ($value === '') {
return [];
}
return array_values(array_filter(array_map(static fn ($item) => (int)$item, explode(',', $value)), static fn ($item) => $item > 0));
}
private function serviceProviderText(string $serviceProvider): string
{
return $serviceProvider === 'zhongjian' ? '中检鉴定' : '实物鉴定';
}
private function categoryVisualMap(Request $request): array
{
$items = (new ContentService())->getHomeConfig()['category_visuals'] ?? [];
if (!is_array($items)) {
return [];
}
$map = [];
$storage = new FileStorageService();
foreach ($items as $item) {
if (!is_array($item)) {
continue;
}
$imageUrl = trim((string)($item['image_url'] ?? ''));
if ($imageUrl === '') {
continue;
}
$imageUrl = $storage->normalizeUrl($imageUrl, $request);
$categoryCode = $this->categoryMatchKey((string)($item['category_code'] ?? ''));
if ($categoryCode !== '') {
$map['code:' . $categoryCode] = $imageUrl;
}
$categoryName = $this->categoryMatchKey((string)($item['category_name'] ?? ''));
if ($categoryName !== '') {
$map['name:' . $categoryName] = $imageUrl;
}
}
return $map;
}
private function saveCategoryVisual(string $categoryName, string $categoryCode, string $imageUrl, ?array $previous = null): void
{
$contentService = new ContentService();
$homeConfig = $contentService->getHomeConfig();
$items = is_array($homeConfig['category_visuals'] ?? null) ? $homeConfig['category_visuals'] : [];
$removeKeys = [
'code:' . $this->categoryMatchKey($categoryCode),
'name:' . $this->categoryMatchKey($categoryName),
];
if ($previous) {
$removeKeys[] = 'code:' . $this->categoryMatchKey((string)($previous['code'] ?? ''));
$removeKeys[] = 'name:' . $this->categoryMatchKey((string)($previous['name'] ?? ''));
}
$removeKeys = array_values(array_filter(array_unique($removeKeys), static fn ($key) => !str_ends_with($key, ':')));
$nextItems = [];
foreach ($items as $item) {
if (!is_array($item)) {
continue;
}
$itemKeys = [
'code:' . $this->categoryMatchKey((string)($item['category_code'] ?? '')),
'name:' . $this->categoryMatchKey((string)($item['category_name'] ?? '')),
];
if (array_intersect($removeKeys, $itemKeys)) {
continue;
}
$nextItems[] = $item;
}
$nextItems[] = [
'category_name' => $categoryName,
'category_code' => $categoryCode,
'image_url' => $imageUrl,
];
$homeConfig['category_visuals'] = $nextItems;
$contentService->saveHomeConfig($homeConfig);
}
private function categoryMatchKey(string $value): string
{
$value = trim($value);
$normalized = preg_replace('/[\s\p{Cf}]+/u', '', $value);
return strtolower($normalized ?? $value);
}
private function templateSampleImageService(): CatalogTemplateSampleImageService
{
return new CatalogTemplateSampleImageService();
}
}

View File

@@ -0,0 +1,155 @@
<?php
namespace app\controller\admin;
use app\support\ContentImageService;
use app\support\ContentService;
use support\Request;
class ContentsController
{
public function bootstrap(Request $request)
{
$service = $this->service();
return api_success([
'home_config' => $service->getHomeConfig(),
'policy_config' => $service->getPolicyConfig(),
'meta_config' => $service->getMetaConfig(),
'help_articles' => $service->getHelpArticles(false),
]);
}
public function home(Request $request)
{
return api_success([
'home_config' => $this->service()->getHomeConfig(),
]);
}
public function saveHome(Request $request)
{
try {
$this->service()->saveHomeConfig((array)$request->input('home_config', []));
return api_success([
'home_config' => $this->service()->getHomeConfig(),
], '首页内容已保存');
} catch (\RuntimeException $e) {
return api_error($e->getMessage(), 422);
} catch (\Throwable $e) {
return api_error('首页内容保存失败', 500, [
'detail' => $e->getMessage(),
]);
}
}
public function uploadImage(Request $request)
{
try {
return api_success((new ContentImageService())->upload($request), '图片已上传');
} catch (\RuntimeException $e) {
return api_error($e->getMessage(), 422);
} catch (\Throwable $e) {
return api_error('图片上传失败', 500, [
'detail' => $e->getMessage(),
]);
}
}
public function helpArticles(Request $request)
{
return api_success([
'list' => $this->service()->getHelpArticles(false),
]);
}
public function policy(Request $request)
{
return api_success([
'policy_config' => $this->service()->getPolicyConfig(),
]);
}
public function savePolicy(Request $request)
{
try {
$this->service()->savePolicyConfig((array)$request->input('policy_config', []));
return api_success([
'policy_config' => $this->service()->getPolicyConfig(),
], '协议与说明已保存');
} catch (\RuntimeException $e) {
return api_error($e->getMessage(), 422);
} catch (\Throwable $e) {
return api_error('协议与说明保存失败', 500, [
'detail' => $e->getMessage(),
]);
}
}
public function meta(Request $request)
{
return api_success([
'meta_config' => $this->service()->getMetaConfig(),
]);
}
public function saveMeta(Request $request)
{
try {
$this->service()->saveMetaConfig((array)$request->input('meta_config', []));
return api_success([
'meta_config' => $this->service()->getMetaConfig(),
], '帮助分类与报告提示已保存');
} catch (\RuntimeException $e) {
return api_error($e->getMessage(), 422);
} catch (\Throwable $e) {
return api_error('帮助分类与报告提示保存失败', 500, [
'detail' => $e->getMessage(),
]);
}
}
public function saveHelpArticle(Request $request)
{
try {
$id = $this->service()->saveHelpArticle([
'id' => (int)$request->input('id', 0),
'category' => trim((string)$request->input('category', 'service')),
'title' => trim((string)$request->input('title', '')),
'summary' => trim((string)$request->input('summary', '')),
'keywords' => (array)$request->input('keywords', []),
'content_blocks' => (array)$request->input('content_blocks', []),
'is_recommended' => (bool)$request->input('is_recommended', false),
'is_enabled' => (bool)$request->input('is_enabled', true),
'sort_order' => (int)$request->input('sort_order', 0),
]);
return api_success(['id' => $id], '帮助文章已保存');
} catch (\RuntimeException $e) {
return api_error($e->getMessage(), 422);
} catch (\Throwable $e) {
return api_error('帮助文章保存失败', 500, [
'detail' => $e->getMessage(),
]);
}
}
public function deleteHelpArticle(Request $request)
{
try {
$this->service()->deleteHelpArticle((int)$request->input('id', 0));
return api_success([], '帮助文章已删除');
} catch (\RuntimeException $e) {
return api_error($e->getMessage(), 422);
} catch (\Throwable $e) {
return api_error('帮助文章删除失败', 500, [
'detail' => $e->getMessage(),
]);
}
}
private function service(): ContentService
{
return new ContentService();
}
}

View File

@@ -0,0 +1,393 @@
<?php
namespace app\controller\admin;
use app\support\EnterpriseCustomerService;
use app\support\EnterpriseOrderService;
use app\support\EnterpriseWebhookService;
use support\Request;
use support\think\Db;
class CustomersController
{
public function index(Request $request)
{
$keyword = trim((string)$request->input('keyword', ''));
$status = trim((string)$request->input('status', ''));
$query = Db::name('enterprise_customers')->order('id', 'desc');
if ($keyword !== '') {
$query->where(function ($builder) use ($keyword) {
$builder->whereRaw(
'(customer_code LIKE :keyword_code OR customer_name LIKE :keyword_name OR contact_name LIKE :keyword_contact OR contact_mobile LIKE :keyword_mobile)',
[
'keyword_code' => "%{$keyword}%",
'keyword_name' => "%{$keyword}%",
'keyword_contact' => "%{$keyword}%",
'keyword_mobile' => "%{$keyword}%",
]
);
});
}
if (in_array($status, ['enabled', 'disabled'], true)) {
$query->where('status', $status);
}
$rows = $query->select()->toArray();
$customerIds = array_map(static fn(array $item) => (int)$item['id'], $rows);
$appCountMap = $this->countMap('enterprise_customer_apps', $customerIds);
$orderCountMap = $this->countMap('enterprise_customer_order_refs', $customerIds);
$eventCountMap = $this->countMap('enterprise_order_events', $customerIds);
return api_success([
'list' => array_map(function (array $item) use ($appCountMap, $orderCountMap, $eventCountMap) {
$customer = $this->customerService()->formatCustomer($item);
$id = (int)$customer['id'];
$customer['app_count'] = (int)($appCountMap[$id] ?? 0);
$customer['order_count'] = (int)($orderCountMap[$id] ?? 0);
$customer['event_count'] = (int)($eventCountMap[$id] ?? 0);
return $customer;
}, $rows),
]);
}
public function detail(Request $request)
{
$id = (int)$request->input('id', 0);
if ($id <= 0) {
return api_error('客户 ID 不能为空', 422);
}
$customer = Db::name('enterprise_customers')->where('id', $id)->find();
if (!$customer) {
return api_error('客户不存在', 404);
}
$apps = Db::name('enterprise_customer_apps')
->where('customer_id', $id)
->order('id', 'desc')
->select()
->toArray();
return api_success([
'customer' => $this->customerService()->formatCustomer($customer),
'apps' => array_map(fn(array $item) => $this->customerService()->formatApp($item), $apps),
]);
}
public function save(Request $request)
{
$id = (int)$request->input('id', 0);
$customerName = trim((string)$request->input('customer_name', ''));
if ($customerName === '') {
return api_error('客户名称不能为空', 422);
}
$status = trim((string)$request->input('status', 'enabled'));
if (!in_array($status, ['enabled', 'disabled'], true)) {
return api_error('客户状态不正确', 422);
}
$webhookUrl = trim((string)$request->input('webhook_url', ''));
if ($webhookUrl !== '' && !preg_match('/^https?:\/\//i', $webhookUrl)) {
return api_error('Webhook URL 必须以 http 或 https 开头', 422);
}
$now = date('Y-m-d H:i:s');
$payload = [
'customer_name' => $customerName,
'contact_name' => trim((string)$request->input('contact_name', '')),
'contact_mobile' => trim((string)$request->input('contact_mobile', '')),
'contact_email' => trim((string)$request->input('contact_email', '')),
'settlement_type' => 'monthly',
'webhook_url' => $webhookUrl,
'webhook_enabled' => $request->input('webhook_enabled', false) ? 1 : 0,
'status' => $status,
'remark' => trim((string)$request->input('remark', '')),
'updated_at' => $now,
];
Db::startTrans();
try {
if ($id > 0) {
$customer = Db::name('enterprise_customers')->where('id', $id)->find();
if (!$customer) {
Db::rollback();
return api_error('客户不存在', 404);
}
Db::name('enterprise_customers')->where('id', $id)->update($payload);
} else {
$payload['customer_code'] = $this->customerService()->generateCustomerCode();
$payload['created_at'] = $now;
$id = (int)Db::name('enterprise_customers')->insertGetId($payload);
}
$customer = Db::name('enterprise_customers')->where('id', $id)->find();
if ($customer) {
$this->customerService()->ensureVirtualUser($customer);
}
Db::commit();
} catch (\Throwable $e) {
Db::rollback();
return api_error('客户保存失败', 500, [
'detail' => $e->getMessage(),
]);
}
return api_success([
'id' => $id,
], $request->input('id', 0) ? '客户已更新' : '客户已创建');
}
public function createApp(Request $request)
{
$customerId = (int)$request->input('customer_id', 0);
$appName = trim((string)$request->input('app_name', '默认应用'));
if ($customerId <= 0) {
return api_error('客户 ID 不能为空', 422);
}
if ($appName === '') {
$appName = '默认应用';
}
$customer = Db::name('enterprise_customers')->where('id', $customerId)->find();
if (!$customer) {
return api_error('客户不存在', 404);
}
$secret = $this->customerService()->generateAppSecret();
$now = date('Y-m-d H:i:s');
$appId = (int)Db::name('enterprise_customer_apps')->insertGetId([
'customer_id' => $customerId,
'app_name' => $appName,
'app_key' => $this->customerService()->generateAppKey(),
'app_secret_cipher' => $this->customerService()->encryptSecret($secret),
'secret_last4' => substr($secret, -4),
'status' => 'enabled',
'last_used_at' => null,
'created_at' => $now,
'updated_at' => $now,
]);
$app = Db::name('enterprise_customer_apps')->where('id', $appId)->find();
return api_success([
'app' => $this->customerService()->formatApp($app),
'app_secret' => $secret,
], '应用 Key 已创建,请立即复制保存 Secret');
}
public function updateAppStatus(Request $request)
{
$id = (int)$request->input('id', 0);
$status = trim((string)$request->input('status', ''));
if ($id <= 0 || !in_array($status, ['enabled', 'disabled'], true)) {
return api_error('应用 ID 或状态不正确', 422);
}
$app = Db::name('enterprise_customer_apps')->where('id', $id)->find();
if (!$app) {
return api_error('应用不存在', 404);
}
Db::name('enterprise_customer_apps')->where('id', $id)->update([
'status' => $status,
'updated_at' => date('Y-m-d H:i:s'),
]);
return api_success([
'id' => $id,
'status' => $status,
], $status === 'enabled' ? '应用已启用' : '应用已停用');
}
public function resetAppSecret(Request $request)
{
$id = (int)$request->input('id', 0);
if ($id <= 0) {
return api_error('应用 ID 不能为空', 422);
}
$app = Db::name('enterprise_customer_apps')->where('id', $id)->find();
if (!$app) {
return api_error('应用不存在', 404);
}
$secret = $this->customerService()->generateAppSecret();
Db::name('enterprise_customer_apps')->where('id', $id)->update([
'app_secret_cipher' => $this->customerService()->encryptSecret($secret),
'secret_last4' => substr($secret, -4),
'updated_at' => date('Y-m-d H:i:s'),
]);
$fresh = Db::name('enterprise_customer_apps')->where('id', $id)->find();
return api_success([
'app' => $this->customerService()->formatApp($fresh),
'app_secret' => $secret,
], '应用 Secret 已重置,请立即复制保存');
}
public function orders(Request $request)
{
$customerId = (int)$request->input('customer_id', 0);
if ($customerId <= 0) {
return api_error('客户 ID 不能为空', 422);
}
$rows = Db::name('enterprise_customer_order_refs')
->alias('r')
->leftJoin('orders o', 'o.id = r.order_id')
->leftJoin('order_products p', 'p.order_id = r.order_id')
->field([
'r.id',
'r.customer_id',
'r.external_order_no',
'r.order_id',
'r.order_no',
'r.appraisal_no',
'r.created_at',
'o.order_status',
'o.display_status',
'o.pay_amount',
'p.product_name',
])
->where('r.customer_id', $customerId)
->order('r.id', 'desc')
->select()
->toArray();
return api_success([
'list' => array_map(static fn(array $item) => [
'id' => (int)$item['id'],
'customer_id' => (int)$item['customer_id'],
'external_order_no' => (string)$item['external_order_no'],
'order_id' => (int)$item['order_id'],
'order_no' => (string)$item['order_no'],
'appraisal_no' => (string)$item['appraisal_no'],
'product_name' => (string)($item['product_name'] ?: '待完善物品信息'),
'order_status' => (string)($item['order_status'] ?? ''),
'display_status' => (string)($item['display_status'] ?? ''),
'pay_amount' => (float)($item['pay_amount'] ?? 0),
'created_at' => (string)$item['created_at'],
], $rows),
]);
}
public function events(Request $request)
{
$customerId = (int)$request->input('customer_id', 0);
if ($customerId <= 0) {
return api_error('客户 ID 不能为空', 422);
}
$rows = Db::name('enterprise_order_events')
->where('customer_id', $customerId)
->order('id', 'desc')
->limit(200)
->select()
->toArray();
return api_success([
'list' => array_map(fn(array $item) => $this->webhookService()->formatEvent($item), $rows),
]);
}
public function deliveries(Request $request)
{
$customerId = (int)$request->input('customer_id', 0);
$eventId = (int)$request->input('event_id', 0);
if ($customerId <= 0 && $eventId <= 0) {
return api_error('客户 ID 或事件 ID 不能为空', 422);
}
$query = Db::name('enterprise_webhook_deliveries')->order('id', 'desc')->limit(200);
if ($customerId > 0) {
$query->where('customer_id', $customerId);
}
if ($eventId > 0) {
$query->where('event_id', $eventId);
}
$rows = $query->select()->toArray();
return api_success([
'list' => array_map(fn(array $item) => $this->webhookService()->formatDelivery($item), $rows),
]);
}
public function resendEvent(Request $request)
{
$eventId = (int)$request->input('event_id', 0);
if ($eventId <= 0) {
return api_error('事件 ID 不能为空', 422);
}
try {
$result = $this->webhookService()->deliverEvent($eventId, true);
} catch (\RuntimeException $e) {
return api_error($e->getMessage(), 422);
} catch (\Throwable $e) {
return api_error('事件补发失败', 500, [
'detail' => $e->getMessage(),
]);
}
return api_success([
'delivery' => $this->webhookService()->formatDelivery($result['delivery']),
'sent' => (bool)$result['sent'],
], $result['sent'] ? '事件已补发成功' : '事件补发未成功,请查看推送记录');
}
public function orderProgress(Request $request)
{
$customerId = (int)$request->input('customer_id', 0);
$externalOrderNo = trim((string)$request->input('external_order_no', ''));
if ($customerId <= 0 || $externalOrderNo === '') {
return api_error('客户 ID 和外部订单号不能为空', 422);
}
$customer = Db::name('enterprise_customers')->where('id', $customerId)->find();
if (!$customer) {
return api_error('客户不存在', 404);
}
try {
$order = (new EnterpriseOrderService())->findOrder($customer, $externalOrderNo, '');
} catch (\Throwable $e) {
return api_error($e->getMessage(), 404);
}
return api_success([
'order' => $order,
]);
}
private function countMap(string $table, array $customerIds): array
{
if (!$customerIds) {
return [];
}
$rows = Db::name($table)
->field('customer_id, COUNT(*) AS total')
->whereIn('customer_id', $customerIds)
->group('customer_id')
->select()
->toArray();
$map = [];
foreach ($rows as $row) {
$map[(int)$row['customer_id']] = (int)$row['total'];
}
return $map;
}
private function customerService(): EnterpriseCustomerService
{
return new EnterpriseCustomerService();
}
private function webhookService(): EnterpriseWebhookService
{
return new EnterpriseWebhookService();
}
}

View File

@@ -0,0 +1,66 @@
<?php
namespace app\controller\admin;
use support\Request;
use support\think\Db;
class DashboardController
{
public function index(Request $request)
{
$totalOrders = (int)Db::name('orders')->count();
$pendingCount = (int)Db::name('orders')->whereIn('order_status', [
'pending_payment',
'pending_submission',
'pending_shipping',
'pending_supplement',
])->count();
$processingCount = (int)Db::name('orders')->whereIn('order_status', [
'received',
'pending_assignment',
'in_first_review',
'in_final_review',
'generating_report',
])->count();
$pendingReturnCount = (int)Db::name('orders')->where('order_status', 'report_published')->count();
$returningCount = (int)Db::name('orders')
->alias('o')
->join('order_logistics l', 'l.order_id = o.id')
->where('o.order_status', 'completed')
->where('l.logistics_type', 'return_to_user')
->where('l.tracking_no', '<>', '')
->where('l.tracking_status', '<>', 'received')
->count();
return api_success([
'cards' => [
[
'title' => '订单总量',
'value' => $totalOrders,
'desc' => '当前数据库内订单总数',
],
[
'title' => '待处理订单',
'value' => $pendingCount,
'desc' => '待支付、待补资料、待寄送等订单',
],
[
'title' => '处理中订单',
'value' => $processingCount,
'desc' => '已进入鉴定流程的订单',
],
[
'title' => '待寄回订单',
'value' => $pendingReturnCount,
'desc' => '报告已出具,等待平台安排回寄',
],
[
'title' => '回寄途中',
'value' => $returningCount,
'desc' => '已登记回寄运单,等待用户签收',
],
],
]);
}
}

View File

@@ -0,0 +1,80 @@
<?php
namespace app\controller\admin;
use app\support\MaterialTagService;
use support\Request;
class MaterialsController
{
public function batches(Request $request)
{
return api_success([
'list' => $this->service()->listBatches([
'keyword' => $request->input('keyword', ''),
'qr_url' => $request->input('qr_url', ''),
'verify_code' => $request->input('verify_code', ''),
'date_start' => $request->input('date_start', ''),
'date_end' => $request->input('date_end', ''),
]),
]);
}
public function detail(Request $request)
{
$id = (int)$request->input('id', 0);
if ($id <= 0) {
return api_error('物料批次 ID 不能为空', 422);
}
try {
return api_success($this->service()->detail($id, trim((string)$request->input('keyword', ''))));
} catch (\RuntimeException $e) {
return api_error($e->getMessage(), $e->getCode() ?: 404);
}
}
public function create(Request $request)
{
$count = (int)$request->input('count', 0);
$remark = trim((string)$request->input('remark', ''));
$adminId = (int)$request->header('x-admin-id', 0);
$adminName = trim((string)$request->header('x-admin-name', ''));
try {
return api_success($this->service()->createBatch($count, $remark, $adminId, $adminName), '物料批次已生成');
} catch (\InvalidArgumentException $e) {
return api_error($e->getMessage(), 422);
} catch (\Throwable $e) {
return api_error('物料批次生成失败', 500, ['detail' => $e->getMessage()]);
}
}
public function download(Request $request)
{
$id = (int)$request->input('id', 0);
if ($id <= 0) {
return api_error('物料批次 ID 不能为空', 422);
}
try {
$file = $this->service()->downloadBatch($id, $request);
} catch (\RuntimeException $e) {
return api_error($e->getMessage(), $e->getCode() ?: 500);
} catch (\Throwable $e) {
return api_error('物料批次下载失败', 500, ['detail' => $e->getMessage()]);
}
$filename = rawurlencode($file['filename']);
return response($file['content'], 200, [
'Content-Type' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'Content-Disposition' => "attachment; filename=\"{$file['filename']}\"; filename*=UTF-8''{$filename}",
'Cache-Control' => 'no-store, no-cache, must-revalidate',
]);
}
private function service(): MaterialTagService
{
return new MaterialTagService();
}
}

View File

@@ -0,0 +1,165 @@
<?php
namespace app\controller\admin;
use support\Request;
use support\think\Db;
class MessagesController
{
public function overview(Request $request)
{
return api_success([
'cards' => [
[
'title' => '启用模板',
'value' => (int)Db::name('message_templates')->where('is_enabled', 1)->count(),
'desc' => '当前已启用的消息模板数量',
],
[
'title' => '触发规则',
'value' => (int)Db::name('message_rules')->where('is_enabled', 1)->count(),
'desc' => '当前启用的消息触发规则数量',
],
[
'title' => '发送日志',
'value' => (int)Db::name('message_logs')->count(),
'desc' => '消息发送记录总数',
],
[
'title' => '站内消息',
'value' => (int)Db::name('user_messages')->count(),
'desc' => '当前已生成的站内消息数量',
],
],
]);
}
public function templates(Request $request)
{
$rows = Db::name('message_templates')
->field([
'id',
'template_name',
'template_code',
'channel',
'event_code',
'title',
'content',
'is_enabled',
])
->order('id', 'asc')
->select()
->toArray();
$list = array_map(function (array $item) {
return [
'id' => (int)$item['id'],
'template_name' => $item['template_name'],
'template_code' => $item['template_code'],
'channel' => $item['channel'],
'channel_text' => $this->channelText($item['channel']),
'event_code' => $item['event_code'],
'title' => $item['title'],
'content' => $item['content'],
'is_enabled' => (bool)$item['is_enabled'],
];
}, $rows);
return api_success(['list' => $list]);
}
public function saveTemplate(Request $request)
{
$id = (int)$request->input('id', 0);
$templateName = trim((string)$request->input('template_name', ''));
$templateCode = trim((string)$request->input('template_code', ''));
$channel = trim((string)$request->input('channel', ''));
$eventCode = trim((string)$request->input('event_code', ''));
if ($templateName === '' || $templateCode === '' || $channel === '' || $eventCode === '') {
return api_error('模板名称、模板编码、发送渠道和触发事件不能为空', 422);
}
$payload = [
'template_name' => $templateName,
'template_code' => $templateCode,
'channel' => $channel,
'event_code' => $eventCode,
'title' => trim((string)$request->input('title', '')),
'content' => trim((string)$request->input('content', '')),
'is_enabled' => $request->input('is_enabled', true) ? 1 : 0,
'updated_at' => date('Y-m-d H:i:s'),
];
if ($id > 0) {
Db::name('message_templates')->where('id', $id)->update($payload);
return api_success(['id' => $id], '更新成功');
}
$payload['created_at'] = date('Y-m-d H:i:s');
$newId = Db::name('message_templates')->insertGetId($payload);
return api_success(['id' => (int)$newId], '创建成功');
}
public function logs(Request $request)
{
$rows = Db::name('message_logs')
->field([
'id',
'user_id',
'template_id',
'biz_type',
'biz_id',
'channel',
'status',
'fail_reason',
'sent_at',
'created_at',
])
->order('id', 'desc')
->select()
->toArray();
$templates = Db::name('message_templates')->column('template_name', 'id');
$list = array_map(function (array $item) use ($templates) {
return [
'id' => (int)$item['id'],
'user_id' => (int)($item['user_id'] ?? 0),
'template_name' => $templates[$item['template_id']] ?? '未知模板',
'biz_type' => $item['biz_type'],
'biz_id' => (int)($item['biz_id'] ?? 0),
'channel' => $item['channel'],
'channel_text' => $this->channelText($item['channel']),
'status' => $item['status'],
'status_text' => $this->logStatusText($item['status']),
'fail_reason' => $item['fail_reason'] ?: '',
'sent_at' => $item['sent_at'],
'created_at' => $item['created_at'],
];
}, $rows);
return api_success(['list' => $list]);
}
private function channelText(string $channel): string
{
return match ($channel) {
'inbox' => '站内消息',
'sms' => '短信',
'wechat_subscribe' => '微信订阅消息',
default => $channel,
};
}
private function logStatusText(string $status): string
{
return match ($status) {
'pending' => '待发送',
'sent' => '已发送',
'failed' => '发送失败',
default => $status,
};
}
}

View File

@@ -0,0 +1,952 @@
<?php
namespace app\controller\admin;
use app\support\MessageDispatcher;
use app\support\EnterpriseWebhookService;
use app\support\WarehouseService;
use support\Request;
use support\think\Db;
class OrdersController
{
public function index(Request $request)
{
$keyword = trim((string)$request->input('keyword', ''));
$status = trim((string)$request->input('status', ''));
$serviceProvider = trim((string)$request->input('service_provider', ''));
$sourceChannel = $this->normalizeOrderSourceChannel((string)$request->input('source_channel', ''));
$query = Db::name('orders')
->alias('o')
->leftJoin('order_products p', 'p.order_id = o.id')
->field([
'o.id',
'o.order_no',
'o.appraisal_no',
'o.service_provider',
'o.order_status',
'o.display_status',
'o.estimated_finish_time',
'o.source_channel',
'o.source_customer_id',
'o.pay_amount',
'o.created_at',
'p.product_name',
'p.category_name',
'p.brand_name',
])
->order('o.id', 'desc');
if ($keyword !== '') {
$query->where(function ($builder) use ($keyword) {
$builder->whereRaw(
'(o.order_no LIKE :keyword_order OR o.appraisal_no LIKE :keyword_appraisal OR p.product_name LIKE :keyword_product)',
[
'keyword_order' => "%{$keyword}%",
'keyword_appraisal' => "%{$keyword}%",
'keyword_product' => "%{$keyword}%",
]
);
});
}
$specialStatusFilters = ['returning', 'completed_signed'];
if ($status !== '' && !in_array($status, $specialStatusFilters, true)) {
$query->where('o.order_status', $status);
}
if ($serviceProvider !== '') {
$query->where('o.service_provider', $serviceProvider);
}
if ($sourceChannel !== '') {
$query->where('o.source_channel', $sourceChannel);
}
$rows = $query->select()->toArray();
$returnTrackingMap = [];
if ($rows) {
$returnRows = Db::name('order_logistics')
->whereIn('order_id', array_column($rows, 'id'))
->where('logistics_type', 'return_to_user')
->order('id', 'desc')
->select()
->toArray();
foreach ($returnRows as $row) {
$orderId = (int)($row['order_id'] ?? 0);
if ($orderId > 0 && !isset($returnTrackingMap[$orderId])) {
$returnTrackingMap[$orderId] = [
'tracking_no' => (string)($row['tracking_no'] ?? ''),
'tracking_status' => (string)($row['tracking_status'] ?? ''),
];
}
}
}
$list = array_map(function (array $item) use ($returnTrackingMap) {
return [
'id' => (int)$item['id'],
'order_no' => $item['order_no'],
'appraisal_no' => $item['appraisal_no'],
'product_name' => $item['product_name'] ?: '待完善物品信息',
'category_name' => $item['category_name'] ?: '',
'brand_name' => $item['brand_name'] ?: '',
'service_provider' => $item['service_provider'],
'service_provider_text' => $item['service_provider'] === 'zhongjian' ? '中检鉴定' : '实物鉴定',
'source_channel' => $this->normalizeOrderSourceChannel((string)($item['source_channel'] ?? '')),
'source_channel_text' => $this->sourceChannelText((string)($item['source_channel'] ?? '')),
'source_customer_id' => (string)($item['source_customer_id'] ?? ''),
'order_status' => $item['order_status'],
'display_status' => $this->displayStatus(
(string)$item['order_status'],
(string)$item['display_status'],
$returnTrackingMap[(int)$item['id']]['tracking_no'] ?? '',
$returnTrackingMap[(int)$item['id']]['tracking_status'] ?? '',
),
'estimated_finish_time' => $item['estimated_finish_time'],
'pay_amount' => (float)$item['pay_amount'],
'created_at' => $item['created_at'],
];
}, $rows);
if ($status === 'returning') {
$list = array_values(array_filter($list, function (array $item) {
return $item['order_status'] === 'completed' && $item['display_status'] === '物品已寄回';
}));
}
if ($status === 'completed_signed') {
$list = array_values(array_filter($list, function (array $item) {
return $item['order_status'] === 'completed' && $item['display_status'] === '已完成';
}));
}
return api_success([
'list' => $list,
]);
}
public function detail(Request $request)
{
$id = (int)$request->input('id', 0);
if (!$id) {
return api_error('订单 ID 不能为空', 422);
}
$order = Db::name('orders')->where('id', $id)->find();
if (!$order) {
return api_error('订单不存在', 404);
}
$product = Db::name('order_products')->where('order_id', $id)->find();
$extra = Db::name('order_extras')->where('order_id', $id)->find();
$sendLogistics = Db::name('order_logistics')
->where('order_id', $id)
->where('logistics_type', 'send_to_center')
->order('id', 'desc')
->find();
$returnLogistics = Db::name('order_logistics')
->where('order_id', $id)
->where('logistics_type', 'return_to_user')
->order('id', 'desc')
->find();
$timeline = Db::name('order_timelines')
->where('order_id', $id)
->order('occurred_at', 'asc')
->select()
->toArray();
$timeline = array_map(fn (array $item) => [
'node_text' => $item['node_text'],
'node_desc' => $item['node_desc'],
'occurred_at' => $item['occurred_at'],
], $timeline);
$supplement = Db::name('order_supplement_tasks')->where('order_id', $id)->order('id', 'desc')->find();
$supplementItems = [];
if ($supplement) {
$supplementItems = Db::name('order_supplement_task_items')
->where('task_id', $supplement['id'])
->select()
->toArray();
$supplementItems = array_map(fn (array $item) => [
'item_name' => $item['item_name'],
'guide_text' => $item['guide_text'],
], $supplementItems);
}
$report = Db::name('reports')->where('order_id', $id)->order('id', 'desc')->find();
$hasPublishedOrderReport = $report && ($report['report_status'] ?? '') === 'published';
$canAttemptReturnLogistics = in_array($order['order_status'], ['report_published', 'completed'], true)
&& (($returnLogistics['tracking_status'] ?? '') !== 'received');
$shippingTarget = Db::name('order_shipping_targets')->where('order_id', $id)->find();
$returnAddress = Db::name('order_return_addresses')->where('order_id', $id)->find();
if (!$returnAddress) {
$returnAddress = Db::name('user_addresses')
->where('user_id', (int)$order['user_id'])
->where('is_default', 1)
->order('id', 'desc')
->find()
?: Db::name('user_addresses')
->where('user_id', (int)$order['user_id'])
->order('id', 'desc')
->find();
if ($returnAddress) {
$returnAddress = [
'user_address_id' => (int)$returnAddress['id'],
'consignee' => $returnAddress['consignee'],
'mobile' => $returnAddress['mobile'],
'province' => $returnAddress['province'],
'city' => $returnAddress['city'],
'district' => $returnAddress['district'],
'detail_address' => $returnAddress['detail_address'],
];
}
}
$logisticsNodes = [];
if ($sendLogistics) {
$logisticsNodes = Db::name('order_logistics_nodes')
->where('logistics_id', $sendLogistics['id'])
->order('node_time', 'desc')
->select()
->toArray();
}
$returnLogisticsNodes = [];
if ($returnLogistics) {
$returnLogisticsNodes = Db::name('order_logistics_nodes')
->where('logistics_id', $returnLogistics['id'])
->order('node_time', 'desc')
->select()
->toArray();
}
return api_success([
'order_info' => [
'id' => (int)$order['id'],
'order_no' => $order['order_no'],
'appraisal_no' => $order['appraisal_no'],
'service_provider' => $order['service_provider'],
'service_provider_text' => $order['service_provider'] === 'zhongjian' ? '中检鉴定' : '实物鉴定',
'source_channel' => $this->normalizeOrderSourceChannel((string)($order['source_channel'] ?? '')),
'source_channel_text' => $this->sourceChannelText((string)($order['source_channel'] ?? '')),
'source_customer_id' => (string)($order['source_customer_id'] ?? ''),
'order_status' => $order['order_status'],
'display_status' => $this->displayStatus(
(string)$order['order_status'],
(string)$order['display_status'],
$returnLogistics['tracking_no'] ?? '',
$returnLogistics['tracking_status'] ?? '',
),
'pay_amount' => (float)$order['pay_amount'],
'estimated_finish_time' => $order['estimated_finish_time'],
'created_at' => $order['created_at'],
'can_reassign_warehouse' => $order['order_status'] === 'pending_shipping' && empty($sendLogistics['tracking_no']),
'can_mark_received' => $order['order_status'] === 'pending_shipping'
&& (!empty($sendLogistics['tracking_no']) || ($order['source_channel'] ?? '') === 'enterprise_push'),
'can_submit_return_logistics' => $hasPublishedOrderReport && $canAttemptReturnLogistics,
'return_logistics_block_reason' => (!$hasPublishedOrderReport && $canAttemptReturnLogistics)
? '订单报告未发布前,物品不允许寄回'
: '',
'can_mark_return_received' => $order['order_status'] === 'completed' && !empty($returnLogistics['tracking_no']) && ($returnLogistics['tracking_status'] ?? '') !== 'received',
],
'product_info' => [
'product_name' => $product['product_name'] ?? '',
'category_id' => (int)($product['category_id'] ?? 0),
'category_name' => $product['category_name'] ?? '',
'brand_id' => (int)($product['brand_id'] ?? 0),
'brand_name' => $product['brand_name'] ?? '',
'color' => $product['color'] ?? '',
'size_spec' => $product['size_spec'] ?? '',
'serial_no' => $product['serial_no'] ?? '',
],
'extra_info' => [
'purchase_channel' => $extra['purchase_channel'] ?? '',
'purchase_price' => (float)($extra['purchase_price'] ?? 0),
'usage_status' => $extra['usage_status'] ?? '',
'condition_desc' => $extra['condition_desc'] ?? '',
'remark' => $extra['remark'] ?? '',
],
'shipping_target' => $shippingTarget ? [
'warehouse_id' => (int)($shippingTarget['warehouse_id'] ?? 0),
'warehouse_name' => $shippingTarget['warehouse_name'],
'warehouse_code' => $shippingTarget['warehouse_code'],
'receiver_name' => $shippingTarget['receiver_name'],
'receiver_mobile' => $shippingTarget['receiver_mobile'],
'full_address' => trim(sprintf(
'%s%s%s%s',
$shippingTarget['province'] ?? '',
$shippingTarget['city'] ?? '',
$shippingTarget['district'] ?? '',
$shippingTarget['detail_address'] ?? ''
)),
'service_time' => $shippingTarget['service_time'],
'notice' => $shippingTarget['notice'],
] : null,
'return_address' => $returnAddress ? [
'user_address_id' => (int)($returnAddress['user_address_id'] ?? 0),
'consignee' => $returnAddress['consignee'],
'mobile' => $returnAddress['mobile'],
'full_address' => trim(sprintf(
'%s%s%s%s',
$returnAddress['province'] ?? '',
$returnAddress['city'] ?? '',
$returnAddress['district'] ?? '',
$returnAddress['detail_address'] ?? ''
)),
] : null,
'timeline' => $timeline,
'logistics_info' => $sendLogistics ? [
'express_company' => $sendLogistics['express_company'],
'tracking_no' => $sendLogistics['tracking_no'],
'tracking_status' => $sendLogistics['tracking_status'],
'tracking_status_text' => $this->trackingStatusText($sendLogistics['tracking_status'], 'send_to_center'),
'latest_desc' => $this->formatAdminLogisticsDesc(
'send_to_center',
$sendLogistics['tracking_status'],
$sendLogistics['express_company'],
$sendLogistics['tracking_no'],
$sendLogistics['latest_desc']
),
'latest_time' => $sendLogistics['latest_time'],
'nodes' => array_map(fn (array $item) => [
'node_time' => $item['node_time'],
'node_desc' => $this->formatAdminLogisticsDesc(
'send_to_center',
$sendLogistics['tracking_status'],
$sendLogistics['express_company'],
$sendLogistics['tracking_no'],
$item['node_desc']
),
'node_location' => $item['node_location'],
], $logisticsNodes),
] : null,
'return_logistics' => $returnLogistics ? [
'express_company' => $returnLogistics['express_company'],
'tracking_no' => $returnLogistics['tracking_no'],
'tracking_status' => $returnLogistics['tracking_status'],
'tracking_status_text' => $this->trackingStatusText($returnLogistics['tracking_status'], 'return_to_user'),
'latest_desc' => $this->formatAdminLogisticsDesc(
'return_to_user',
$returnLogistics['tracking_status'],
$returnLogistics['express_company'],
$returnLogistics['tracking_no'],
$returnLogistics['latest_desc']
),
'latest_time' => $returnLogistics['latest_time'],
'nodes' => array_map(fn (array $item) => [
'node_time' => $item['node_time'],
'node_desc' => $this->formatAdminLogisticsDesc(
'return_to_user',
$returnLogistics['tracking_status'],
$returnLogistics['express_company'],
$returnLogistics['tracking_no'],
$item['node_desc']
),
'node_location' => $item['node_location'],
], $returnLogisticsNodes),
] : null,
'supplement_task' => $supplement ? [
'reason' => $supplement['reason'],
'deadline' => $supplement['deadline'],
'status' => $supplement['status'],
'items' => $supplementItems,
] : null,
'report_summary' => $report ? [
'report_no' => $report['report_no'],
'report_title' => $report['report_title'],
'report_status' => $report['report_status'],
'publish_time' => $report['publish_time'],
] : null,
]);
}
public function warehouseOptions(Request $request)
{
$id = (int)$request->input('id', 0);
if ($id <= 0) {
return api_error('订单 ID 不能为空', 422);
}
$order = Db::name('orders')->where('id', $id)->find();
if (!$order) {
return api_error('订单不存在', 404);
}
$product = Db::name('order_products')->where('order_id', $id)->find();
$options = (new WarehouseService())->optionsForOrder(
(string)($order['service_provider'] ?? 'anxinyan'),
!empty($product['category_id']) ? (int)$product['category_id'] : null
);
return api_success([
'list' => $options,
]);
}
public function reassignWarehouse(Request $request)
{
$id = (int)$request->input('id', 0);
$warehouseId = (int)$request->input('warehouse_id', 0);
if ($id <= 0 || $warehouseId <= 0) {
return api_error('订单 ID 和仓库 ID 不能为空', 422);
}
$order = Db::name('orders')->where('id', $id)->find();
if (!$order) {
return api_error('订单不存在', 404);
}
$logistics = Db::name('order_logistics')
->where('order_id', $id)
->where('logistics_type', 'send_to_center')
->order('id', 'desc')
->find();
if ($order['order_status'] !== 'pending_shipping' || !empty($logistics['tracking_no'])) {
return api_error('当前订单已进入寄送流程,暂不支持改派仓库', 422);
}
$warehouse = Db::name('shipping_warehouses')
->where('id', $warehouseId)
->where('status', 'enabled')
->find();
if (!$warehouse) {
return api_error('目标仓库不存在或已停用', 404);
}
$product = Db::name('order_products')->where('order_id', $id)->find();
$categoryId = !empty($product['category_id']) ? (int)$product['category_id'] : null;
$allowedWarehouses = (new WarehouseService())->optionsForOrder((string)$order['service_provider'], $categoryId);
$allowedIds = array_column($allowedWarehouses, 'id');
if (!in_array($warehouseId, $allowedIds, true)) {
return api_error('目标仓库不适用于当前订单服务类型或品类', 422);
}
$currentTarget = Db::name('order_shipping_targets')->where('order_id', $id)->find();
if ($currentTarget && (int)($currentTarget['warehouse_id'] ?? 0) === $warehouseId) {
return api_error('当前订单已绑定该仓库,无需重复改派', 422);
}
$snapshot = [
'warehouse_id' => (int)$warehouse['id'],
'warehouse_name' => $warehouse['warehouse_name'],
'warehouse_code' => $warehouse['warehouse_code'],
'receiver_name' => $warehouse['receiver_name'],
'receiver_mobile' => $warehouse['receiver_mobile'],
'province' => $warehouse['province'],
'city' => $warehouse['city'],
'district' => $warehouse['district'],
'detail_address' => $warehouse['detail_address'],
'service_time' => $warehouse['service_time'],
'notice' => $warehouse['notice'],
];
$now = date('Y-m-d H:i:s');
Db::startTrans();
try {
(new WarehouseService())->bindOrderTarget($id, (string)$order['service_provider'], $categoryId);
Db::name('order_shipping_targets')->where('order_id', $id)->update([
'warehouse_id' => $snapshot['warehouse_id'],
'warehouse_name' => $snapshot['warehouse_name'],
'warehouse_code' => $snapshot['warehouse_code'],
'service_provider' => $order['service_provider'],
'receiver_name' => $snapshot['receiver_name'],
'receiver_mobile' => $snapshot['receiver_mobile'],
'province' => $snapshot['province'],
'city' => $snapshot['city'],
'district' => $snapshot['district'],
'detail_address' => $snapshot['detail_address'],
'service_time' => $snapshot['service_time'],
'notice' => $snapshot['notice'],
'updated_at' => $now,
]);
Db::name('order_timelines')->insert([
'order_id' => $id,
'node_code' => 'warehouse_reassigned',
'node_text' => '仓库已改派',
'node_desc' => sprintf('订单收货仓库已改派至 %s', $snapshot['warehouse_name']),
'operator_type' => 'admin',
'operator_id' => 1,
'occurred_at' => $now,
'created_at' => $now,
]);
Db::commit();
} catch (\Throwable $e) {
Db::rollback();
return api_error('仓库改派失败', 500, [
'detail' => $e->getMessage(),
]);
}
return api_success([
'id' => $id,
'warehouse_id' => $snapshot['warehouse_id'],
'warehouse_name' => $snapshot['warehouse_name'],
], '仓库已改派');
}
public function receiveLogistics(Request $request)
{
$id = (int)$request->input('id', 0);
if ($id <= 0) {
return api_error('订单 ID 不能为空', 422);
}
$order = Db::name('orders')->where('id', $id)->find();
if (!$order) {
return api_error('订单不存在', 404);
}
$logistics = Db::name('order_logistics')
->where('order_id', $id)
->where('logistics_type', 'send_to_center')
->order('id', 'desc')
->find();
$allowEnterpriseManualReceive = ($order['source_channel'] ?? '') === 'enterprise_push';
if ((!$logistics || $logistics['tracking_no'] === '') && !$allowEnterpriseManualReceive) {
return api_error('当前订单还没有有效运单信息', 422);
}
if ($order['order_status'] !== 'pending_shipping') {
return api_error('当前订单状态不支持标记签收', 422);
}
$now = date('Y-m-d H:i:s');
$latestDesc = '鉴定中心已签收包裹,等待鉴定师开始处理。';
Db::startTrans();
try {
if ($logistics) {
Db::name('order_logistics')->where('id', $logistics['id'])->update([
'tracking_status' => 'received',
'latest_desc' => $latestDesc,
'latest_time' => $now,
'updated_at' => $now,
]);
$logisticsId = (int)$logistics['id'];
} else {
$latestDesc = '大客户推送订单已确认到仓,等待鉴定师开始处理。';
$logisticsId = (int)Db::name('order_logistics')->insertGetId([
'order_id' => $id,
'logistics_type' => 'send_to_center',
'express_company' => '',
'tracking_no' => '',
'tracking_status' => 'received',
'latest_desc' => $latestDesc,
'latest_time' => $now,
'created_at' => $now,
'updated_at' => $now,
]);
}
Db::name('order_logistics_nodes')->insert([
'logistics_id' => $logisticsId,
'node_time' => $now,
'node_desc' => $latestDesc,
'node_location' => '鉴定中心',
'created_at' => $now,
]);
Db::name('orders')->where('id', $id)->update([
'order_status' => 'in_first_review',
'display_status' => '鉴定中',
'updated_at' => $now,
]);
$taskUpdate = [
'status' => 'processing',
'updated_at' => $now,
];
$task = Db::name('appraisal_tasks')
->where('order_id', $id)
->where('task_stage', 'first_review')
->order('id', 'asc')
->find();
if ($task && empty($task['started_at'])) {
$taskUpdate['started_at'] = $now;
}
if ($task) {
Db::name('appraisal_tasks')->where('id', (int)$task['id'])->update($taskUpdate);
}
Db::name('order_timelines')->insert([
'order_id' => $id,
'node_code' => 'first_review',
'node_text' => '鉴定中',
'node_desc' => $logistics
? '包裹已由鉴定中心签收,订单已进入鉴定流程'
: '大客户推送订单已确认到仓,订单已进入鉴定流程',
'operator_type' => 'admin',
'operator_id' => 1,
'occurred_at' => $now,
'created_at' => $now,
]);
Db::commit();
} catch (\Throwable $e) {
Db::rollback();
return api_error('标记签收失败', 500, [
'detail' => $e->getMessage(),
]);
}
(new EnterpriseWebhookService())->recordOrderEvent($id, 'inbound_received', [
'express_company' => (string)($logistics['express_company'] ?? ''),
'tracking_no' => (string)($logistics['tracking_no'] ?? ''),
'received_at' => $now,
]);
return api_success(['id' => $id], '已标记鉴定中心签收');
}
public function saveReturnLogistics(Request $request)
{
$id = (int)$request->input('id', 0);
$expressCompany = trim((string)$request->input('express_company', ''));
$trackingNo = trim((string)$request->input('tracking_no', ''));
if ($id <= 0 || $expressCompany === '' || $trackingNo === '') {
return api_error('订单、快递公司和运单号不能为空', 422);
}
$order = Db::name('orders')->where('id', $id)->find();
if (!$order) {
return api_error('订单不存在', 404);
}
if (!in_array($order['order_status'], ['report_published', 'completed'], true)) {
return api_error('当前订单状态不支持登记回寄运单', 422);
}
$report = Db::name('reports')->where('order_id', $id)->order('id', 'desc')->find();
if (!$report || ($report['report_status'] ?? '') !== 'published') {
return api_error('订单报告未发布前,物品不允许寄回', 422);
}
$returnAddress = Db::name('order_return_addresses')->where('order_id', $id)->find();
if (!$returnAddress) {
$fallbackAddress = Db::name('user_addresses')
->where('user_id', (int)$order['user_id'])
->where('is_default', 1)
->order('id', 'desc')
->find()
?: Db::name('user_addresses')
->where('user_id', (int)$order['user_id'])
->order('id', 'desc')
->find();
if (!$fallbackAddress) {
return api_error('当前订单尚未确认寄回地址,且用户账户下没有可用地址', 422);
}
$returnAddress = [
'user_address_id' => (int)$fallbackAddress['id'],
'consignee' => $fallbackAddress['consignee'],
'mobile' => $fallbackAddress['mobile'],
'province' => $fallbackAddress['province'],
'city' => $fallbackAddress['city'],
'district' => $fallbackAddress['district'],
'detail_address' => $fallbackAddress['detail_address'],
];
}
$existing = Db::name('order_logistics')
->where('order_id', $id)
->where('logistics_type', 'return_to_user')
->order('id', 'desc')
->find();
$now = date('Y-m-d H:i:s');
$latestDesc = sprintf('平台已通过 %s 回寄商品,运单号 %s。', $expressCompany, $trackingNo);
Db::startTrans();
try {
$existingReturnAddress = Db::name('order_return_addresses')->where('order_id', $id)->find();
if (!$existingReturnAddress) {
Db::name('order_return_addresses')->insert([
'order_id' => $id,
'user_address_id' => $returnAddress['user_address_id'] ?? null,
'consignee' => $returnAddress['consignee'] ?? '',
'mobile' => $returnAddress['mobile'] ?? '',
'province' => $returnAddress['province'] ?? '',
'city' => $returnAddress['city'] ?? '',
'district' => $returnAddress['district'] ?? '',
'detail_address' => $returnAddress['detail_address'] ?? '',
'created_at' => $now,
'updated_at' => $now,
]);
}
if ($existing) {
Db::name('order_logistics')->where('id', $existing['id'])->update([
'logistics_type' => 'return_to_user',
'express_company' => $expressCompany,
'tracking_no' => $trackingNo,
'tracking_status' => 'in_transit',
'latest_desc' => $latestDesc,
'latest_time' => $now,
'updated_at' => $now,
]);
$logisticsId = (int)$existing['id'];
$nodeText = '已更新回寄运单';
$nodeDesc = sprintf('平台更新回寄运单:%s %s', $expressCompany, $trackingNo);
} else {
$logisticsId = (int)Db::name('order_logistics')->insertGetId([
'order_id' => $id,
'logistics_type' => 'return_to_user',
'express_company' => $expressCompany,
'tracking_no' => $trackingNo,
'tracking_status' => 'in_transit',
'latest_desc' => $latestDesc,
'latest_time' => $now,
'created_at' => $now,
'updated_at' => $now,
]);
$nodeText = '已寄回用户';
$nodeDesc = sprintf('平台已通过 %s 回寄商品,运单号 %s', $expressCompany, $trackingNo);
}
Db::name('order_logistics_nodes')->insert([
'logistics_id' => $logisticsId,
'node_time' => $now,
'node_desc' => $latestDesc,
'node_location' => $returnAddress['city'] ?? '用户地址',
'created_at' => $now,
]);
Db::name('orders')->where('id', $id)->update([
'order_status' => 'completed',
'display_status' => '物品已寄回',
'updated_at' => $now,
]);
Db::name('order_timelines')->insert([
'order_id' => $id,
'node_code' => 'return_shipped',
'node_text' => $nodeText,
'node_desc' => $nodeDesc,
'operator_type' => 'admin',
'operator_id' => 1,
'occurred_at' => $now,
'created_at' => $now,
]);
(new MessageDispatcher())->sendInboxEvent('return_shipped', [
'user_id' => (int)($order['user_id'] ?? 0),
'biz_type' => 'return_shipped',
'biz_id' => $id,
'express_company' => $expressCompany,
'tracking_no' => $trackingNo,
'fallback_title' => '鉴定物品已寄回',
'fallback_content' => sprintf('平台已通过%s回寄鉴定物品运单号 %s可前往订单详情查看物流进度。', $expressCompany, $trackingNo),
]);
Db::commit();
} catch (\Throwable $e) {
Db::rollback();
return api_error('回寄运单登记失败', 500, [
'detail' => $e->getMessage(),
]);
}
(new EnterpriseWebhookService())->recordOrderEvent($id, 'return_shipped', [
'express_company' => $expressCompany,
'tracking_no' => $trackingNo,
'shipped_at' => $now,
]);
return api_success([
'id' => $id,
'express_company' => $expressCompany,
'tracking_no' => $trackingNo,
], '回寄运单已登记');
}
public function receiveReturnLogistics(Request $request)
{
$id = (int)$request->input('id', 0);
if ($id <= 0) {
return api_error('订单 ID 不能为空', 422);
}
$order = Db::name('orders')->where('id', $id)->find();
if (!$order) {
return api_error('订单不存在', 404);
}
$logistics = Db::name('order_logistics')
->where('order_id', $id)
->where('logistics_type', 'return_to_user')
->order('id', 'desc')
->find();
if (!$logistics || $logistics['tracking_no'] === '') {
return api_error('当前订单还没有有效回寄运单', 422);
}
if (($logistics['tracking_status'] ?? '') === 'received') {
return api_error('当前订单已标记用户签收,无需重复操作', 422);
}
$now = date('Y-m-d H:i:s');
$latestDesc = '用户已签收回寄商品,本次订单已完成。';
Db::startTrans();
try {
Db::name('order_logistics')->where('id', $logistics['id'])->update([
'tracking_status' => 'received',
'latest_desc' => $latestDesc,
'latest_time' => $now,
'updated_at' => $now,
]);
Db::name('order_logistics_nodes')->insert([
'logistics_id' => $logistics['id'],
'node_time' => $now,
'node_desc' => $latestDesc,
'node_location' => '用户地址',
'created_at' => $now,
]);
Db::name('orders')->where('id', $id)->update([
'order_status' => 'completed',
'display_status' => '已完成',
'updated_at' => $now,
]);
Db::name('order_timelines')->insert([
'order_id' => $id,
'node_code' => 'return_received',
'node_text' => '用户已签收',
'node_desc' => '回寄商品已由用户签收,本次订单已完成。',
'operator_type' => 'admin',
'operator_id' => 1,
'occurred_at' => $now,
'created_at' => $now,
]);
(new MessageDispatcher())->sendInboxEvent('return_received', [
'user_id' => (int)($order['user_id'] ?? 0),
'biz_type' => 'return_received',
'biz_id' => $id,
'fallback_title' => '回寄商品已签收',
'fallback_content' => '系统已确认您签收回寄商品,本次鉴定订单已完成。',
]);
Db::commit();
} catch (\Throwable $e) {
Db::rollback();
return api_error('标记用户签收失败', 500, [
'detail' => $e->getMessage(),
]);
}
(new EnterpriseWebhookService())->recordOrderEvent($id, 'completed', [
'express_company' => (string)($logistics['express_company'] ?? ''),
'tracking_no' => (string)($logistics['tracking_no'] ?? ''),
'completed_at' => $now,
]);
return api_success(['id' => $id], '已标记用户签收');
}
private function trackingStatusText(string $status, string $logisticsType = 'send_to_center'): string
{
if ($logisticsType === 'return_to_user') {
return match ($status) {
'submitted' => '已登记回寄运单',
'in_transit' => '回寄途中',
'received' => '用户已签收',
default => $status === '' ? '待回寄' : $status,
};
}
return match ($status) {
'submitted' => '用户已提交运单',
'in_transit' => '用户已寄出,运输中',
'received' => '鉴定中心已签收',
default => $status === '' ? '待提交' : $status,
};
}
private function displayStatus(string $orderStatus, string $displayStatus, string $returnTrackingNo = '', string $returnTrackingStatus = ''): string
{
if ($orderStatus === 'report_published') {
return '待寄回';
}
if ($orderStatus === 'completed') {
if ($returnTrackingStatus === 'received') {
return '已完成';
}
if ($returnTrackingNo !== '') {
return '物品已寄回';
}
}
return $displayStatus;
}
private function normalizeOrderSourceChannel(string $sourceChannel): string
{
$sourceChannel = trim($sourceChannel);
$aliases = [
'wechat_mini_program' => 'mini_program',
'weixin_mini_program' => 'mini_program',
'mp_weixin' => 'mini_program',
'miniapp' => 'mini_program',
'user_app' => 'mini_program',
'web_h5' => 'h5',
'enterprise' => 'enterprise_push',
'enterprise_order' => 'enterprise_push',
'customer_push' => 'enterprise_push',
'large_customer_push' => 'enterprise_push',
];
$sourceChannel = $aliases[$sourceChannel] ?? $sourceChannel;
return in_array($sourceChannel, ['mini_program', 'h5', 'enterprise_push'], true) ? $sourceChannel : '';
}
private function sourceChannelText(string $sourceChannel): string
{
return match ($this->normalizeOrderSourceChannel($sourceChannel)) {
'mini_program' => '小程序',
'h5' => 'H5',
'enterprise_push' => '大客户推送订单',
default => '未知渠道',
};
}
private function formatAdminLogisticsDesc(string $logisticsType, string $status, string $expressCompany, string $trackingNo, string $fallback): string
{
$expressCompany = trim($expressCompany);
$trackingNo = trim($trackingNo);
if ($logisticsType === 'return_to_user') {
if (in_array($status, ['submitted', 'in_transit'], true) && $expressCompany !== '' && $trackingNo !== '') {
return sprintf('平台已登记回寄运单:%s %s商品正在回寄途中。', $expressCompany, $trackingNo);
}
if ($status === 'received') {
return '用户已签收回寄商品,订单已完成。';
}
return $fallback;
}
if ($status === 'submitted' && $expressCompany !== '' && $trackingNo !== '') {
return sprintf('用户已提交寄送运单:%s %s等待鉴定中心签收。', $expressCompany, $trackingNo);
}
if ($status === 'in_transit' && $expressCompany !== '' && $trackingNo !== '') {
return sprintf('用户已寄出商品:%s %s当前运输中。', $expressCompany, $trackingNo);
}
if ($status === 'received') {
return '鉴定中心已签收包裹,等待鉴定师开始处理。';
}
return $fallback;
}
}

View File

@@ -0,0 +1,705 @@
<?php
namespace app\controller\admin;
use app\support\AppraisalEvidenceService;
use app\support\ContentService;
use app\support\EnterpriseWebhookService;
use app\support\MessageDispatcher;
use support\Request;
use support\think\Db;
class ReportsController
{
public function index(Request $request)
{
$keyword = trim((string)$request->input('keyword', ''));
$status = trim((string)$request->input('status', ''));
$serviceProvider = trim((string)$request->input('service_provider', ''));
$query = Db::name('reports')
->alias('r')
->leftJoin('orders o', 'o.id = r.order_id')
->leftJoin('order_products p', 'p.order_id = r.order_id')
->field([
'r.id',
'r.report_no',
'r.order_id',
'r.appraisal_no',
'r.report_type',
'r.report_title',
'r.report_status',
'r.service_provider',
'r.institution_name',
'r.publish_time',
'o.order_no',
'p.product_name',
'p.category_name',
'p.brand_name',
])
->order('r.id', 'desc');
if ($status !== '') {
$query->where('r.report_status', $status);
}
if ($serviceProvider !== '') {
$query->where('r.service_provider', $serviceProvider);
}
$rows = $query->select()->toArray();
$contentMap = $this->loadReportContentMap(array_map(fn(array $item) => (int)$item['id'], $rows));
$list = [];
foreach ($rows as $item) {
$productSnapshot = $contentMap[(int)$item['id']]['product_snapshot'] ?? [];
$mapped = [
'id' => (int)$item['id'],
'order_id' => (int)($item['order_id'] ?? 0),
'order_no' => $item['order_no'] ?? '',
'appraisal_no' => $item['appraisal_no'] ?? '',
'report_no' => $item['report_no'],
'report_type' => $item['report_type'] ?: 'appraisal',
'report_type_text' => $this->reportTypeText($item['report_type'] ?: 'appraisal'),
'report_title' => $item['report_title'],
'report_status' => $item['report_status'],
'report_status_text' => $this->reportStatusText($item['report_status']),
'service_provider' => $item['service_provider'],
'service_provider_text' => $this->serviceProviderText($item['service_provider']),
'institution_name' => $item['institution_name'] ?: $this->defaultInstitutionName($item['service_provider']),
'publish_time' => $item['publish_time'],
'product_name' => $item['product_name'] ?: (string)($productSnapshot['product_name'] ?? ''),
'category_name' => $item['category_name'] ?: (string)($productSnapshot['category_name'] ?? ''),
'brand_name' => $item['brand_name'] ?: (string)($productSnapshot['brand_name'] ?? ''),
];
if ($keyword !== '' && !$this->matchKeyword($mapped, $keyword)) {
continue;
}
$list[] = $mapped;
}
return api_success(['list' => $list]);
}
public function detail(Request $request)
{
$id = (int)$request->input('id', 0);
if (!$id) {
return api_error('报告 ID 不能为空', 422);
}
$report = Db::name('reports')->where('id', $id)->find();
if (!$report) {
return api_error('报告不存在', 404);
}
$content = Db::name('report_contents')->where('report_id', $id)->find();
$productSnapshot = $this->decodeJsonField($content['product_snapshot_json'] ?? null);
$resultSnapshot = $this->decodeJsonField($content['result_snapshot_json'] ?? null);
$appraisalSnapshot = $this->decodeJsonField($content['appraisal_snapshot_json'] ?? null);
$valuationSnapshot = $this->decodeJsonField($content['valuation_snapshot_json'] ?? null);
$appraisalSnapshot = $this->enrichAppraisalSnapshot($report, $appraisalSnapshot);
$evidenceAttachments = $this->evidenceService()->normalize($content['evidence_attachments_json'] ?? null, $request);
$verify = Db::name('report_verifies')->where('report_id', $id)->find() ?: [];
if (($report['report_status'] ?? '') === 'published') {
$verify = $this->createOrUpdateVerifyRecord($report, date('Y-m-d H:i:s'));
}
$reportPageUrl = $this->buildPublicPageUrl('/pages/report/detail', ['report_no' => $report['report_no']]);
$verifyUrl = $this->buildPublicPageUrl('/pages/verify/result', ['report_no' => $report['report_no']]);
if (!$verify) {
$verify = [];
}
$verify['report_page_url'] = $verify['report_page_url'] ?? $reportPageUrl;
$verify['verify_qrcode_url'] = $verify['verify_qrcode_url'] ?? $reportPageUrl;
$verify['verify_url'] = $verify['verify_url'] ?? $verifyUrl;
$defaultRiskNotice = (new ContentService())->getReportRiskNotice((string)($report['report_type'] ?? 'appraisal'));
return api_success([
'report_header' => [
'id' => (int)$report['id'],
'order_id' => (int)($report['order_id'] ?? 0),
'report_no' => $report['report_no'],
'report_type' => $report['report_type'] ?: 'appraisal',
'report_type_text' => $this->reportTypeText($report['report_type'] ?: 'appraisal'),
'report_title' => $report['report_title'],
'report_status' => $report['report_status'],
'report_status_text' => $this->reportStatusText($report['report_status']),
'service_provider' => $report['service_provider'],
'service_provider_text' => $this->serviceProviderText($report['service_provider']),
'institution_name' => $report['institution_name'] ?: $this->defaultInstitutionName($report['service_provider']),
'publish_time' => $report['publish_time'],
],
'product_info' => $productSnapshot,
'result_info' => $resultSnapshot,
'appraisal_info' => $appraisalSnapshot,
'valuation_info' => $valuationSnapshot,
'evidence_attachments' => $evidenceAttachments,
'risk_notice_text' => ($content['risk_notice_text'] ?? '') !== '' ? $content['risk_notice_text'] : $defaultRiskNotice,
'verify_info' => [
'verify_status' => $verify['verify_status'] ?? (($report['report_status'] ?? '') === 'published' ? 'valid' : 'pending'),
'verify_url' => $verify['verify_url'] ?? $verifyUrl,
'verify_qrcode_url' => $verify['verify_qrcode_url'] ?? $reportPageUrl,
'report_page_url' => $verify['report_page_url'] ?? $reportPageUrl,
'verify_count' => (int)($verify['verify_count'] ?? 0),
],
]);
}
public function saveInspection(Request $request)
{
$id = (int)$request->input('id', 0);
$header = $request->input('report_header', []);
$productInfo = $request->input('product_info', []);
$resultInfo = $request->input('result_info', []);
$appraisalInfo = $request->input('appraisal_info', []);
$valuationInfo = $request->input('valuation_info', []);
$riskNoticeText = trim((string)$request->input('risk_notice_text', ''));
if (!is_array($header) || !is_array($productInfo) || !is_array($resultInfo) || !is_array($appraisalInfo) || !is_array($valuationInfo)) {
return api_error('检查单参数格式错误', 422);
}
$serviceProvider = trim((string)($header['service_provider'] ?? 'anxinyan'));
if (!in_array($serviceProvider, ['anxinyan', 'zhongjian'], true)) {
return api_error('服务类型不正确', 422);
}
$reportStatus = trim((string)($header['report_status'] ?? 'pending_publish'));
if (!in_array($reportStatus, ['draft', 'pending_publish', 'published'], true)) {
return api_error('报告状态不正确', 422);
}
$productName = trim((string)($productInfo['product_name'] ?? ''));
$resultText = trim((string)($resultInfo['result_text'] ?? ''));
if ($productName === '') {
return api_error('商品名称不能为空', 422);
}
if ($resultText === '') {
return api_error('鉴定结论不能为空', 422);
}
$now = date('Y-m-d H:i:s');
Db::startTrans();
try {
$existing = null;
if ($id > 0) {
$existing = Db::name('reports')->where('id', $id)->find();
if (!$existing || (($existing['report_type'] ?? 'appraisal') !== 'inspection')) {
Db::rollback();
return api_error('检查单不存在', 404);
}
if (($existing['report_status'] ?? '') === 'published') {
Db::rollback();
return api_error('已发布的检查单不支持直接编辑,请复制后重新补录', 422);
}
}
$reportNo = trim((string)($header['report_no'] ?? ($existing['report_no'] ?? '')));
if ($reportNo === '') {
$reportNo = $this->generateUniqueReportNo('inspection');
}
$conflict = Db::name('reports')
->where('report_no', $reportNo)
->when($id > 0, fn($query) => $query->where('id', '<>', $id))
->find();
if ($conflict) {
Db::rollback();
return api_error('检查单编号已存在,请更换后重试', 422);
}
$reportTitle = trim((string)($header['report_title'] ?? ''));
if ($reportTitle === '') {
$reportTitle = $this->defaultReportTitle($serviceProvider, 'inspection');
}
$institutionName = trim((string)($header['institution_name'] ?? ''));
if ($institutionName === '') {
$institutionName = $this->defaultInstitutionName($serviceProvider);
}
$publishTime = $reportStatus === 'published'
? trim((string)($header['publish_time'] ?? ($existing['publish_time'] ?? $now)))
: null;
$reportPayload = [
'report_no' => $reportNo,
'order_id' => 0,
'appraisal_no' => $existing['appraisal_no'] ?? $this->generateUniqueAppraisalNo('inspection'),
'report_type' => 'inspection',
'service_provider' => $serviceProvider,
'institution_name' => $institutionName,
'report_title' => $reportTitle,
'report_status' => $reportStatus,
'report_version' => $existing ? ((int)$existing['report_version'] + 1) : 1,
'publish_time' => $publishTime ?: null,
'invalid_reason' => '',
'updated_at' => $now,
];
if ($existing) {
Db::name('reports')->where('id', $id)->update($reportPayload);
$reportId = $id;
} else {
$reportPayload['created_at'] = $now;
$reportId = (int)Db::name('reports')->insertGetId($reportPayload);
}
$normalizedProductInfo = [
'product_name' => $productName,
'category_name' => trim((string)($productInfo['category_name'] ?? '')),
'brand_name' => trim((string)($productInfo['brand_name'] ?? '')),
'color' => trim((string)($productInfo['color'] ?? '')),
'size_spec' => trim((string)($productInfo['size_spec'] ?? '')),
'serial_no' => trim((string)($productInfo['serial_no'] ?? '')),
];
$normalizedResultInfo = [
'result_status' => trim((string)($resultInfo['result_status'] ?? 'authentic')),
'result_text' => $resultText,
'result_desc' => trim((string)($resultInfo['result_desc'] ?? '')),
];
$normalizedAppraisalInfo = [
'service_provider' => $serviceProvider,
'institution_name' => $institutionName,
'appraiser_name' => trim((string)($appraisalInfo['appraiser_name'] ?? '')),
'reviewer_name' => trim((string)($appraisalInfo['reviewer_name'] ?? '')),
'appraisal_time' => trim((string)($appraisalInfo['appraisal_time'] ?? ($publishTime ?: $now))),
];
$normalizedValuationInfo = [
'condition_grade' => trim((string)($valuationInfo['condition_grade'] ?? '')),
'condition_desc' => trim((string)($valuationInfo['condition_desc'] ?? '')),
'valuation_min' => (float)($valuationInfo['valuation_min'] ?? 0),
'valuation_max' => (float)($valuationInfo['valuation_max'] ?? 0),
'valuation_desc' => trim((string)($valuationInfo['valuation_desc'] ?? '')),
];
$contentPayload = [
'report_id' => $reportId,
'product_snapshot_json' => json_encode($normalizedProductInfo, JSON_UNESCAPED_UNICODE),
'result_snapshot_json' => json_encode($normalizedResultInfo, JSON_UNESCAPED_UNICODE),
'appraisal_snapshot_json' => json_encode($normalizedAppraisalInfo, JSON_UNESCAPED_UNICODE),
'valuation_snapshot_json' => json_encode($normalizedValuationInfo, JSON_UNESCAPED_UNICODE),
'risk_notice_text' => $riskNoticeText !== '' ? $riskNoticeText : (new ContentService())->getReportRiskNotice('inspection'),
'updated_at' => $now,
];
$content = Db::name('report_contents')->where('report_id', $reportId)->find();
if ($content) {
Db::name('report_contents')->where('report_id', $reportId)->update($contentPayload);
} else {
$contentPayload['created_at'] = $now;
Db::name('report_contents')->insert($contentPayload);
}
$reportRecord = Db::name('reports')->where('id', $reportId)->find();
$verifyInfo = [
'verify_url' => '',
'report_page_url' => '',
];
if ($reportStatus === 'published' && $reportRecord) {
$verifyInfo = $this->createOrUpdateVerifyRecord($reportRecord, $now);
} else {
Db::name('report_verifies')->where('report_id', $reportId)->delete();
}
Db::commit();
return api_success([
'id' => $reportId,
'report_status' => $reportStatus,
'publish_time' => $publishTime ?: '',
'verify_url' => $verifyInfo['verify_url'] ?? '',
'report_page_url' => $verifyInfo['report_page_url'] ?? '',
], $existing ? '检查单已更新' : '检查单已补录');
} catch (\Throwable $e) {
Db::rollback();
return api_error('检查单保存失败', 500, [
'detail' => $e->getMessage(),
]);
}
}
public function publish(Request $request)
{
$id = (int)$request->input('id', 0);
if (!$id) {
return api_error('报告 ID 不能为空', 422);
}
$now = date('Y-m-d H:i:s');
Db::startTrans();
try {
$report = Db::name('reports')->where('id', $id)->find();
if (!$report) {
Db::rollback();
return api_error('报告不存在', 404);
}
if (!in_array($report['report_status'], ['draft', 'pending_publish', 'updated', 'published'], true)) {
Db::rollback();
return api_error('当前报告状态不支持发布', 422);
}
$effectivePublishTime = $report['publish_time'] ?: $now;
if ($report['report_status'] !== 'published') {
Db::name('reports')->where('id', $id)->update([
'report_status' => 'published',
'publish_time' => $effectivePublishTime,
'updated_at' => $now,
]);
$report['report_status'] = 'published';
$report['publish_time'] = $effectivePublishTime;
}
if (($report['report_type'] ?? 'appraisal') === 'appraisal' && (int)($report['order_id'] ?? 0) > 0) {
$this->refreshAppraisalSnapshot((int)$report['id'], (int)$report['order_id'], $report['service_provider'], $now);
}
$verify = $this->createOrUpdateVerifyRecord($report, $now);
if (($report['report_type'] ?? 'appraisal') === 'appraisal' && (int)($report['order_id'] ?? 0) > 0) {
Db::name('orders')->where('id', $report['order_id'])->update([
'order_status' => 'report_published',
'display_status' => '报告已出具',
'updated_at' => $now,
]);
$order = Db::name('orders')->where('id', $report['order_id'])->find();
$product = Db::name('order_products')->where('order_id', $report['order_id'])->find();
$timelineExists = Db::name('order_timelines')
->where('order_id', $report['order_id'])
->where('node_code', 'report_published')
->where('node_text', '报告已出具')
->find();
if (!$timelineExists) {
Db::name('order_timelines')->insert([
'order_id' => $report['order_id'],
'node_code' => 'report_published',
'node_text' => '报告已出具',
'node_desc' => '正式报告已发布,用户可查看报告并进行验真。',
'operator_type' => 'admin',
'operator_id' => 1,
'occurred_at' => $now,
'created_at' => $now,
]);
}
(new MessageDispatcher())->sendInboxEvent('report_published', [
'user_id' => (int)($order['user_id'] ?? 0),
'biz_type' => 'report',
'biz_id' => (int)$report['id'],
'report_no' => $report['report_no'],
'report_title' => $report['report_title'],
'product_name' => $product['product_name'] ?? '',
'publish_time' => $report['publish_time'] ?: $now,
'verify_url' => $verify['verify_url'],
'fallback_title' => '报告已出具',
'fallback_content' => '您的正式报告已生成,可前往报告中心查看并完成验真。',
]);
}
Db::commit();
if (($report['report_type'] ?? 'appraisal') === 'appraisal' && (int)($report['order_id'] ?? 0) > 0) {
(new EnterpriseWebhookService())->recordOrderEvent((int)$report['order_id'], 'report_published', [
'report_id' => $id,
'report_no' => (string)$report['report_no'],
'report_title' => (string)$report['report_title'],
'publish_time' => $effectivePublishTime,
'verify_url' => (string)($verify['verify_url'] ?? ''),
'report_page_url' => (string)($verify['report_page_url'] ?? ''),
]);
}
return api_success([
'id' => $id,
'report_status' => 'published',
'publish_time' => $effectivePublishTime,
'verify_url' => $verify['verify_url'],
'report_page_url' => $verify['report_page_url'],
], '报告已发布');
} catch (\Throwable $e) {
Db::rollback();
return api_error('报告发布失败', 500, [
'detail' => $e->getMessage(),
]);
}
}
private function reportStatusText(string $status): string
{
return match ($status) {
'draft' => '草稿中',
'pending_publish' => '待发布',
'published' => '已发布',
'updated' => '已更新',
'invalid' => '已作废',
default => $status,
};
}
private function reportTypeText(string $reportType): string
{
return match ($reportType) {
'inspection' => '补录检查单',
default => '订单报告',
};
}
private function serviceProviderText(string $serviceProvider): string
{
return $serviceProvider === 'zhongjian' ? '中检鉴定' : '实物鉴定';
}
private function decodeJsonField(mixed $value): array
{
if (is_array($value)) {
return $value;
}
if (is_string($value) && $value !== '') {
$decoded = json_decode($value, true);
return is_array($decoded) ? $decoded : [];
}
return [];
}
private function loadReportContentMap(array $reportIds): array
{
if (!$reportIds) {
return [];
}
$rows = Db::name('report_contents')->whereIn('report_id', $reportIds)->select()->toArray();
$map = [];
foreach ($rows as $row) {
$map[(int)$row['report_id']] = [
'product_snapshot' => $this->decodeJsonField($row['product_snapshot_json'] ?? null),
'result_snapshot' => $this->decodeJsonField($row['result_snapshot_json'] ?? null),
];
}
return $map;
}
private function matchKeyword(array $item, string $keyword): bool
{
$needle = mb_strtolower($keyword);
foreach (['report_no', 'report_title', 'product_name', 'brand_name', 'institution_name', 'order_no', 'appraisal_no'] as $field) {
if (str_contains(mb_strtolower((string)($item[$field] ?? '')), $needle)) {
return true;
}
}
return false;
}
private function createOrUpdateVerifyRecord(array $report, string $now): array
{
$reportNo = (string)$report['report_no'];
$verifyToken = 'verify_' . strtolower((string)preg_replace('/[^a-zA-Z0-9]/', '', $reportNo));
$verifyUrl = $this->buildPublicPageUrl('/pages/verify/result', ['report_no' => $reportNo]);
$reportPageUrl = $this->buildPublicPageUrl('/pages/report/detail', ['report_no' => $reportNo]);
$payload = [
'report_id' => (int)$report['id'],
'report_no' => $reportNo,
'verify_token' => $verifyToken,
'verify_qrcode_url' => $reportPageUrl,
'verify_url' => $verifyUrl,
'verify_status' => 'valid',
'updated_at' => $now,
];
$verify = Db::name('report_verifies')->where('report_id', $report['id'])->find();
if ($verify) {
Db::name('report_verifies')->where('id', $verify['id'])->update($payload);
} else {
$payload['last_verified_at'] = null;
$payload['verify_count'] = 0;
$payload['created_at'] = $now;
Db::name('report_verifies')->insert($payload);
}
$fresh = Db::name('report_verifies')->where('report_id', $report['id'])->find() ?: $payload;
$fresh['report_page_url'] = $reportPageUrl;
return $fresh;
}
private function buildPublicPageUrl(string $pagePath, array $query = []): string
{
$baseUrl = $this->normalizeH5BaseUrl($this->getSystemConfigValue('h5', 'page_base_url'));
$page = ltrim($pagePath, '/');
$queryString = http_build_query($query);
$hashPath = '/#/' . $page;
if ($queryString !== '') {
$hashPath .= '?' . $queryString;
}
if ($baseUrl === '') {
return $hashPath;
}
return $baseUrl . $hashPath;
}
private function normalizeH5BaseUrl(string $value): string
{
$baseUrl = trim($value);
if ($baseUrl === '') {
return '';
}
$hashPos = strpos($baseUrl, '#');
if ($hashPos !== false) {
$baseUrl = substr($baseUrl, 0, $hashPos);
}
if (!preg_match('/^https?:\/\//i', $baseUrl)) {
$baseUrl = 'https://' . ltrim($baseUrl, '/');
}
return rtrim($baseUrl, '/');
}
private function enrichAppraisalSnapshot(array $report, array $snapshot): array
{
if (($report['report_type'] ?? 'appraisal') !== 'appraisal' || (int)($report['order_id'] ?? 0) <= 0) {
return $snapshot;
}
$tasks = Db::name('appraisal_tasks')
->where('order_id', (int)$report['order_id'])
->order('id', 'asc')
->select()
->toArray();
$firstReviewTask = null;
$finalReviewTask = null;
foreach ($tasks as $task) {
if (($task['task_stage'] ?? '') === 'first_review') {
$firstReviewTask = $task;
}
if (($task['task_stage'] ?? '') === 'final_review') {
$finalReviewTask = $task;
}
}
$institutionName = $snapshot['institution_name'] ?? ($report['institution_name'] ?: $this->defaultInstitutionName($report['service_provider']));
$appraiserName = $this->normalizeAssigneeName($snapshot['appraiser_name'] ?? '')
?: $this->normalizeAssigneeName($firstReviewTask['assignee_name'] ?? '')
?: $this->normalizeAssigneeName($finalReviewTask['assignee_name'] ?? '');
$reviewerName = $appraiserName;
$appraisalTime = $snapshot['appraisal_time']
?? ($firstReviewTask['submitted_at']
?? $firstReviewTask['started_at']
?? $finalReviewTask['submitted_at']
?? $finalReviewTask['started_at']
?? '');
$snapshot['service_provider'] = $snapshot['service_provider'] ?? $report['service_provider'];
$snapshot['institution_name'] = $institutionName;
$snapshot['appraiser_name'] = $appraiserName;
$snapshot['reviewer_name'] = $reviewerName;
$snapshot['appraisal_time'] = $appraisalTime;
return $snapshot;
}
private function refreshAppraisalSnapshot(int $reportId, int $orderId, string $serviceProvider, string $now): void
{
$content = Db::name('report_contents')->where('report_id', $reportId)->find();
if (!$content) {
return;
}
$snapshot = $this->enrichAppraisalSnapshot(
[
'report_type' => 'appraisal',
'order_id' => $orderId,
'service_provider' => $serviceProvider,
'institution_name' => '',
],
$this->decodeJsonField($content['appraisal_snapshot_json'] ?? null),
);
Db::name('report_contents')->where('report_id', $reportId)->update([
'appraisal_snapshot_json' => json_encode($snapshot, JSON_UNESCAPED_UNICODE),
'updated_at' => $now,
]);
}
private function normalizeAssigneeName(?string $value): string
{
$name = trim((string)$value);
if ($name === '' || $name === '未分配') {
return '';
}
return $name;
}
private function evidenceService(): AppraisalEvidenceService
{
return new AppraisalEvidenceService();
}
private function getSystemConfigValue(string $groupCode, string $configKey): string
{
$row = Db::name('system_configs')
->where('config_group', $groupCode)
->where('config_key', $configKey)
->find();
return trim((string)($row['config_value'] ?? ''));
}
private function generateUniqueReportNo(string $reportType): string
{
$prefix = $reportType === 'inspection' ? 'AXY-CHK' : 'AXY-R';
for ($i = 0; $i < 20; $i++) {
$candidate = sprintf('%s-%s-%04d', $prefix, date('Ymd'), random_int(0, 9999));
if (!Db::name('reports')->where('report_no', $candidate)->find()) {
return $candidate;
}
}
return sprintf('%s-%s-%s', $prefix, date('YmdHis'), random_int(1000, 9999));
}
private function generateUniqueAppraisalNo(string $reportType): string
{
$prefix = $reportType === 'inspection' ? 'AXY-CHECK' : 'AXY-APP';
for ($i = 0; $i < 20; $i++) {
$candidate = sprintf('%s-%s-%04d', $prefix, date('Ymd'), random_int(0, 9999));
if (!Db::name('reports')->where('appraisal_no', $candidate)->find()) {
return $candidate;
}
}
return sprintf('%s-%s-%s', $prefix, date('YmdHis'), random_int(1000, 9999));
}
private function defaultReportTitle(string $serviceProvider, string $reportType): string
{
if ($reportType === 'inspection') {
return $serviceProvider === 'zhongjian' ? '中检检查单' : '安心验检查单';
}
return $serviceProvider === 'zhongjian' ? '中检鉴定报告' : '安心验鉴定报告';
}
private function defaultInstitutionName(string $serviceProvider): string
{
return $serviceProvider === 'zhongjian' ? '中检合作机构' : '安心验';
}
}

View File

@@ -0,0 +1,480 @@
<?php
namespace app\controller\admin;
use app\support\FileStorageConfigService;
use support\Request;
use support\think\Db;
class SystemConfigsController
{
public function index(Request $request)
{
$this->bootstrapDefaults();
$configs = Db::name('system_configs')
->whereIn('config_group', array_keys($this->definitions()))
->order('config_group', 'asc')
->order('config_key', 'asc')
->select()
->toArray();
$configMap = [];
foreach ($configs as $item) {
$configMap[$item['config_group'] . '.' . $item['config_key']] = $item['config_value'] ?? '';
}
$groups = [];
foreach ($this->definitions() as $groupCode => $group) {
$groups[] = [
'group_code' => $groupCode,
'group_name' => $group['group_name'],
'group_desc' => $group['group_desc'],
'items' => array_map(function (array $item) use ($groupCode, $configMap) {
return [
'config_key' => $item['config_key'],
'title' => $item['title'],
'field_type' => $item['field_type'],
'placeholder' => $item['placeholder'],
'remark' => $item['remark'],
'is_secret' => (bool)$item['is_secret'],
'options' => $item['options'] ?? [],
'visible_when' => $item['visible_when'] ?? null,
'value' => $configMap[$groupCode . '.' . $item['config_key']] ?? '',
];
}, $group['items']),
];
}
return api_success(['groups' => $groups]);
}
public function save(Request $request)
{
$items = $request->input('items', []);
if (!is_array($items) || !$items) {
return api_error('配置项不能为空', 422);
}
$definitions = $this->definitions();
$allowedMap = [];
foreach ($definitions as $groupCode => $group) {
foreach ($group['items'] as $item) {
$allowedMap[$groupCode . '.' . $item['config_key']] = true;
}
}
$configValueMap = [];
foreach ($definitions as $groupCode => $group) {
foreach ($group['items'] as $item) {
$configValueMap[$groupCode . '.' . $item['config_key']] = (string)Db::name('system_configs')
->where('config_group', $groupCode)
->where('config_key', $item['config_key'])
->value('config_value');
}
}
foreach ($items as $item) {
if (!is_array($item)) {
continue;
}
$groupCode = trim((string)($item['config_group'] ?? ''));
$configKey = trim((string)($item['config_key'] ?? ''));
$mapKey = $groupCode . '.' . $configKey;
if ($groupCode === '' || $configKey === '' || !isset($allowedMap[$mapKey])) {
continue;
}
$configValueMap[$mapKey] = (string)($item['config_value'] ?? '');
}
try {
$this->validateConfigValues($configValueMap);
} catch (\RuntimeException $e) {
return api_error($e->getMessage(), 422);
}
$now = date('Y-m-d H:i:s');
Db::startTrans();
try {
foreach ($items as $item) {
if (!is_array($item)) {
continue;
}
$groupCode = trim((string)($item['config_group'] ?? ''));
$configKey = trim((string)($item['config_key'] ?? ''));
$configValue = (string)($item['config_value'] ?? '');
$mapKey = $groupCode . '.' . $configKey;
if ($groupCode === '' || $configKey === '' || !isset($allowedMap[$mapKey])) {
continue;
}
$exists = Db::name('system_configs')
->where('config_group', $groupCode)
->where('config_key', $configKey)
->find();
$payload = [
'config_group' => $groupCode,
'config_key' => $configKey,
'config_value' => $configValue,
'remark' => '后台系统配置',
'updated_at' => $now,
];
if ($exists) {
Db::name('system_configs')->where('id', $exists['id'])->update($payload);
} else {
$payload['created_at'] = $now;
Db::name('system_configs')->insert($payload);
}
}
Db::commit();
(new FileStorageConfigService())->clearCache();
} catch (\Throwable $e) {
Db::rollback();
return api_error('系统配置保存失败', 500, [
'detail' => $e->getMessage(),
]);
}
return api_success([], '系统配置已保存');
}
public function uploadFile(Request $request)
{
$groupCode = trim((string)$request->input('config_group', ''));
$configKey = trim((string)$request->input('config_key', ''));
if ($groupCode === '' || $configKey === '') {
return api_error('配置分组和配置项不能为空', 422);
}
$allowed = $this->uploadableConfigMap();
$mapKey = $groupCode . '.' . $configKey;
if (!isset($allowed[$mapKey])) {
return api_error('当前配置项不支持文件上传', 422);
}
$file = $request->file('file');
if (!$file || !$file->isValid()) {
return api_error('上传文件无效', 422);
}
$originalName = (string)$file->getUploadName();
$extension = strtolower((string)$file->getUploadExtension());
if ($extension !== 'pem') {
return api_error('仅支持上传 .pem 文件', 422);
}
$content = file_get_contents($file->getRealPath());
if (!is_string($content) || !str_contains($content, '-----BEGIN')) {
return api_error('PEM 文件内容格式不正确', 422);
}
$storageDir = base_path() . '/storage/payment-certs';
if (!is_dir($storageDir)) {
mkdir($storageDir, 0775, true);
}
$targetFilename = $allowed[$mapKey]['filename'];
$targetPath = $storageDir . '/' . $targetFilename;
file_put_contents($targetPath, $content);
@chmod($targetPath, 0600);
$now = date('Y-m-d H:i:s');
$exists = Db::name('system_configs')
->where('config_group', $groupCode)
->where('config_key', $configKey)
->find();
$payload = [
'config_group' => $groupCode,
'config_key' => $configKey,
'config_value' => $targetPath,
'remark' => '后台系统配置',
'updated_at' => $now,
];
if ($exists) {
Db::name('system_configs')->where('id', $exists['id'])->update($payload);
} else {
$payload['created_at'] = $now;
Db::name('system_configs')->insert($payload);
}
return api_success([
'config_group' => $groupCode,
'config_key' => $configKey,
'config_value' => $targetPath,
'file_name' => $targetFilename,
'original_name' => $originalName,
], '文件已上传');
}
private function bootstrapDefaults(): void
{
$now = date('Y-m-d H:i:s');
foreach ($this->definitions() as $groupCode => $group) {
foreach ($group['items'] as $item) {
$exists = Db::name('system_configs')
->where('config_group', $groupCode)
->where('config_key', $item['config_key'])
->find();
if ($exists) {
continue;
}
Db::name('system_configs')->insert([
'config_group' => $groupCode,
'config_key' => $item['config_key'],
'config_value' => (string)($item['default_value'] ?? ''),
'remark' => '后台系统配置',
'created_at' => $now,
'updated_at' => $now,
]);
}
}
}
private function definitions(): array
{
return [
'file_storage' => [
'group_name' => '文件存储',
'group_desc' => '配置业务文件存储方式。支持本地磁盘或阿里云 OSS切换为 OSS 后需填写对应 Bucket 与密钥资料。',
'items' => [
[
'config_key' => 'driver',
'title' => '存储驱动',
'field_type' => 'select',
'placeholder' => '请选择文件存储方式',
'remark' => '本地模式写入服务器 public/uploadsOSS 模式写入阿里云对象存储。',
'is_secret' => false,
'default_value' => 'local',
'options' => [
['label' => '本地存储', 'value' => 'local'],
['label' => '阿里云 OSS', 'value' => 'oss'],
['label' => '七牛云 Kodo', 'value' => 'qiniu'],
],
],
[
'config_key' => 'public_base_url',
'title' => '公开访问域名',
'field_type' => 'text',
'placeholder' => '例如 https://api.anxinjianyan.com 或 https://static.example.com',
'remark' => '用于生成文件公网访问地址;本地可填 API 域名OSS 可填自定义 CDN/回源域名,不填则按驱动自动推导。',
'is_secret' => false,
],
[
'config_key' => 'oss_endpoint',
'title' => 'OSS Endpoint',
'field_type' => 'text',
'placeholder' => '例如 oss-cn-shenzhen.aliyuncs.com',
'remark' => '填写 Bucket 所在地域的公网 Endpoint。',
'is_secret' => false,
'visible_when' => ['config_key' => 'driver', 'equals' => 'oss'],
],
[
'config_key' => 'oss_bucket',
'title' => 'OSS Bucket',
'field_type' => 'text',
'placeholder' => '请输入 Bucket 名称',
'remark' => '将作为所有业务文件的目标 Bucket。',
'is_secret' => false,
'visible_when' => ['config_key' => 'driver', 'equals' => 'oss'],
],
[
'config_key' => 'oss_access_key_id',
'title' => 'OSS AccessKey ID',
'field_type' => 'text',
'placeholder' => '请输入 OSS AccessKey ID',
'remark' => '用于 OSS 文件上传、删除和存在性校验。',
'is_secret' => false,
'visible_when' => ['config_key' => 'driver', 'equals' => 'oss'],
],
[
'config_key' => 'oss_access_key_secret',
'title' => 'OSS AccessKey Secret',
'field_type' => 'password',
'placeholder' => '请输入 OSS AccessKey Secret',
'remark' => '请妥善保管,仅后台可见。',
'is_secret' => true,
'visible_when' => ['config_key' => 'driver', 'equals' => 'oss'],
],
[
'config_key' => 'oss_bucket_domain',
'title' => 'OSS 绑定域名',
'field_type' => 'text',
'placeholder' => '例如 https://static.anxinjianyan.com',
'remark' => '如 Bucket 已绑定自定义域名,可填写;不填则默认使用 https://bucket.endpoint。',
'is_secret' => false,
'visible_when' => ['config_key' => 'driver', 'equals' => 'oss'],
],
[
'config_key' => 'oss_path_prefix',
'title' => 'OSS 路径前缀',
'field_type' => 'text',
'placeholder' => '例如 anxinyan-prod',
'remark' => '可选。填写后 OSS 对象会统一写入此前缀目录下。',
'is_secret' => false,
'visible_when' => ['config_key' => 'driver', 'equals' => 'oss'],
],
[
'config_key' => 'qiniu_bucket',
'title' => '七牛 Bucket',
'field_type' => 'text',
'placeholder' => '请输入七牛 Kodo Bucket 名称',
'remark' => '将作为七牛云对象存储的目标 Bucket。',
'is_secret' => false,
'visible_when' => ['config_key' => 'driver', 'equals' => 'qiniu'],
],
[
'config_key' => 'qiniu_access_key',
'title' => '七牛 AccessKey',
'field_type' => 'text',
'placeholder' => '请输入七牛 AccessKey',
'remark' => '用于七牛文件上传、删除和存在性校验。',
'is_secret' => false,
'visible_when' => ['config_key' => 'driver', 'equals' => 'qiniu'],
],
[
'config_key' => 'qiniu_secret_key',
'title' => '七牛 SecretKey',
'field_type' => 'password',
'placeholder' => '请输入七牛 SecretKey',
'remark' => '请妥善保管,仅后台可见。',
'is_secret' => true,
'visible_when' => ['config_key' => 'driver', 'equals' => 'qiniu'],
],
[
'config_key' => 'qiniu_bucket_domain',
'title' => '七牛公网访问域名',
'field_type' => 'text',
'placeholder' => '例如 https://static.example.com 或 https://xxx.clouddn.com',
'remark' => '用于生成七牛文件公网访问地址。建议填写已绑定并可公开访问的域名。',
'is_secret' => false,
'visible_when' => ['config_key' => 'driver', 'equals' => 'qiniu'],
],
[
'config_key' => 'qiniu_path_prefix',
'title' => '七牛路径前缀',
'field_type' => 'text',
'placeholder' => '例如 anxinyan-prod',
'remark' => '可选。填写后七牛对象会统一写入此前缀目录下。',
'is_secret' => false,
'visible_when' => ['config_key' => 'driver', 'equals' => 'qiniu'],
],
],
],
'mini_program' => [
'group_name' => '小程序配置',
'group_desc' => '配置微信小程序 AppID、密钥及消息通知相关参数。',
'items' => [
['config_key' => 'app_id', 'title' => '小程序 AppID', 'field_type' => 'text', 'placeholder' => '请输入小程序 AppID', 'remark' => '用于小程序登录、消息与支付能力接入', 'is_secret' => false],
['config_key' => 'app_secret', 'title' => '小程序 AppSecret', 'field_type' => 'password', 'placeholder' => '请输入小程序 AppSecret', 'remark' => '请妥善保管,仅后台可见', 'is_secret' => true],
['config_key' => 'original_id', 'title' => '原始 ID', 'field_type' => 'text', 'placeholder' => '请输入原始 ID', 'remark' => '用于公众号/小程序主体识别', 'is_secret' => false],
],
],
'h5' => [
'group_name' => 'H5 配置',
'group_desc' => '配置 H5 接入、开放平台、回调地址以及公开页面域名。',
'items' => [
['config_key' => 'app_id', 'title' => 'H5 AppID', 'field_type' => 'text', 'placeholder' => '请输入 H5 AppID', 'remark' => '用于 H5 登录与开放平台接入', 'is_secret' => false],
['config_key' => 'app_secret', 'title' => 'H5 AppSecret', 'field_type' => 'password', 'placeholder' => '请输入 H5 AppSecret', 'remark' => '请妥善保管,仅后台可见', 'is_secret' => true],
['config_key' => 'oauth_redirect_url', 'title' => '授权回调地址', 'field_type' => 'text', 'placeholder' => '请输入 H5 授权回调地址', 'remark' => '用于 H5 登录或支付回调', 'is_secret' => false],
['config_key' => 'page_base_url', 'title' => 'H5 页面根地址', 'field_type' => 'text', 'placeholder' => '例如 https://m.anxinyan.com', 'remark' => '用于生成扫码查看报告和验真页的完整 H5 链接', 'is_secret' => false],
],
],
'payment' => [
'group_name' => '支付与商户平台',
'group_desc' => '配置微信支付商户号、API 密钥、证书序列号等上线必要参数。',
'items' => [
['config_key' => 'mch_id', 'title' => '商户号 MchID', 'field_type' => 'text', 'placeholder' => '请输入商户号', 'remark' => '微信支付商户平台分配的商户号', 'is_secret' => false],
['config_key' => 'api_v3_key', 'title' => 'APIv3 Key', 'field_type' => 'password', 'placeholder' => '请输入 APIv3 Key', 'remark' => '用于微信支付接口验签与解密', 'is_secret' => true],
['config_key' => 'merchant_serial_no', 'title' => '商户证书序列号', 'field_type' => 'text', 'placeholder' => '请输入商户证书序列号', 'remark' => '与商户 API 证书匹配', 'is_secret' => false],
['config_key' => 'apiclient_key_path', 'title' => 'apiclient_key.pem', 'field_type' => 'file', 'placeholder' => '请上传 apiclient_key.pem', 'remark' => '上传微信支付商户私钥文件,系统将保存到后端非公开目录', 'is_secret' => true],
['config_key' => 'apiclient_cert_path', 'title' => 'apiclient_cert.pem', 'field_type' => 'file', 'placeholder' => '请上传 apiclient_cert.pem', 'remark' => '上传微信支付商户证书文件,系统将保存到后端非公开目录', 'is_secret' => false],
['config_key' => 'merchant_private_key', 'title' => '商户私钥', 'field_type' => 'textarea', 'placeholder' => '请输入商户私钥内容', 'remark' => '用于支付签名,请妥善保管', 'is_secret' => true],
['config_key' => 'platform_certificate_serial', 'title' => '平台证书序列号', 'field_type' => 'text', 'placeholder' => '请输入微信支付平台证书序列号', 'remark' => '用于平台证书校验', 'is_secret' => false],
['config_key' => 'notify_url', 'title' => '支付回调地址', 'field_type' => 'text', 'placeholder' => '请输入支付回调通知地址', 'remark' => '支付成功后用于回调业务系统', 'is_secret' => false],
],
],
'sms' => [
'group_name' => '短信配置',
'group_desc' => '配置阿里云短信服务 AccessKey、签名和登录验证码模板用于手机号验证码登录。',
'items' => [
['config_key' => 'access_key_id', 'title' => 'AccessKey ID', 'field_type' => 'text', 'placeholder' => '请输入阿里云 AccessKey ID', 'remark' => '用于调用阿里云短信 SendSms 接口', 'is_secret' => false],
['config_key' => 'access_key_secret', 'title' => 'AccessKey Secret', 'field_type' => 'password', 'placeholder' => '请输入阿里云 AccessKey Secret', 'remark' => '请妥善保管,仅后台可见', 'is_secret' => true],
['config_key' => 'sign_name', 'title' => '短信签名', 'field_type' => 'text', 'placeholder' => '请输入短信签名', 'remark' => '需与阿里云短信服务已审核通过的签名一致', 'is_secret' => false],
['config_key' => 'login_template_code', 'title' => '登录模板 Code', 'field_type' => 'text', 'placeholder' => '例如 SMS_123456789', 'remark' => '模板中需包含 code 变量', 'is_secret' => false],
['config_key' => 'region_id', 'title' => 'Region ID', 'field_type' => 'text', 'placeholder' => '默认 cn-hangzhou', 'remark' => '通常填写 cn-hangzhou', 'is_secret' => false],
['config_key' => 'endpoint', 'title' => '短信 Endpoint', 'field_type' => 'text', 'placeholder' => '默认可留空', 'remark' => '如不填写则按 SDK 默认规则解析', 'is_secret' => false],
],
],
];
}
private function uploadableConfigMap(): array
{
return [
'payment.apiclient_key_path' => [
'filename' => 'apiclient_key.pem',
],
'payment.apiclient_cert_path' => [
'filename' => 'apiclient_cert.pem',
],
];
}
private function validateConfigValues(array $configValueMap): void
{
$driver = (new FileStorageConfigService())->normalizeDriver((string)($configValueMap['file_storage.driver'] ?? 'local'));
if ($driver === 'local') {
return;
}
if ($driver === 'oss') {
$required = [
'file_storage.oss_endpoint' => 'OSS Endpoint',
'file_storage.oss_bucket' => 'OSS Bucket',
'file_storage.oss_access_key_id' => 'OSS AccessKey ID',
'file_storage.oss_access_key_secret' => 'OSS AccessKey Secret',
];
foreach ($required as $key => $label) {
if (trim((string)($configValueMap[$key] ?? '')) === '') {
throw new \RuntimeException(sprintf('当前已切换为 OSS 存储,请先填写 %s', $label));
}
}
return;
}
if ($driver !== 'qiniu') {
return;
}
$required = [
'file_storage.qiniu_bucket' => '七牛 Bucket',
'file_storage.qiniu_access_key' => '七牛 AccessKey',
'file_storage.qiniu_secret_key' => '七牛 SecretKey',
];
foreach ($required as $key => $label) {
if (trim((string)($configValueMap[$key] ?? '')) === '') {
throw new \RuntimeException(sprintf('当前已切换为七牛云存储,请先填写 %s', $label));
}
}
$publicBaseUrl = trim((string)($configValueMap['file_storage.public_base_url'] ?? ''));
$bucketDomain = trim((string)($configValueMap['file_storage.qiniu_bucket_domain'] ?? ''));
if ($publicBaseUrl === '' && $bucketDomain === '') {
throw new \RuntimeException('当前已切换为七牛云存储,请至少填写公开访问域名或七牛公网访问域名');
}
}
}

View File

@@ -0,0 +1,344 @@
<?php
namespace app\controller\admin;
use app\support\ContentService;
use app\support\MessageDispatcher;
use app\support\TicketAttachmentService;
use support\Request;
use support\think\Db;
class TicketsController
{
public function overview(Request $request)
{
return api_success([
'cards' => [
[
'title' => '工单总量',
'value' => (int)Db::name('tickets')->count(),
'desc' => '当前数据库内工单总数',
],
[
'title' => '待处理工单',
'value' => (int)Db::name('tickets')->whereIn('status', ['pending', 'processing'])->count(),
'desc' => '待处理与处理中工单数量',
],
[
'title' => '已解决工单',
'value' => (int)Db::name('tickets')->where('status', 'resolved')->count(),
'desc' => '当前已解决的工单数量',
],
[
'title' => '工单留言',
'value' => (int)Db::name('ticket_messages')->count(),
'desc' => '当前工单消息记录总数',
],
],
]);
}
public function index(Request $request)
{
$keyword = trim((string)$request->input('keyword', ''));
$status = trim((string)$request->input('status', ''));
$type = trim((string)$request->input('ticket_type', ''));
$query = Db::name('tickets')
->field([
'id',
'ticket_no',
'ticket_type',
'biz_type',
'biz_id',
'order_id',
'user_id',
'status',
'priority',
'assignee_id',
'title',
'created_at',
'updated_at',
])
->order('id', 'desc');
if ($keyword !== '') {
$query->where(function ($builder) use ($keyword) {
$builder->whereLike('ticket_no', "%{$keyword}%")
->whereOrLike('title', "%{$keyword}%");
});
}
if ($status !== '') {
$query->where('status', $status);
}
if ($type !== '') {
$query->where('ticket_type', $type);
}
$rows = $query->select()->toArray();
$list = array_map(function (array $item) {
return [
'id' => (int)$item['id'],
'ticket_no' => $item['ticket_no'],
'ticket_type' => $item['ticket_type'],
'ticket_type_text' => $this->ticketTypeText($item['ticket_type']),
'biz_type' => $item['biz_type'],
'biz_id' => (int)($item['biz_id'] ?? 0),
'order_id' => (int)($item['order_id'] ?? 0),
'user_id' => (int)($item['user_id'] ?? 0),
'status' => $item['status'],
'status_text' => $this->statusText($item['status']),
'priority' => $item['priority'],
'priority_text' => $this->priorityText($item['priority']),
'title' => $item['title'] ?: '未命名工单',
'created_at' => $item['created_at'],
'updated_at' => $item['updated_at'],
];
}, $rows);
return api_success(['list' => $list]);
}
public function detail(Request $request)
{
$id = (int)$request->input('id', 0);
if (!$id) {
return api_error('工单 ID 不能为空', 422);
}
$ticket = Db::name('tickets')->where('id', $id)->find();
if (!$ticket) {
return api_error('工单不存在', 404);
}
$messages = Db::name('ticket_messages')
->where('ticket_id', $id)
->order('id', 'asc')
->select()
->toArray();
return api_success([
'ticket_info' => [
'id' => (int)$ticket['id'],
'ticket_no' => $ticket['ticket_no'],
'ticket_type' => $ticket['ticket_type'],
'ticket_type_text' => $this->ticketTypeText($ticket['ticket_type']),
'biz_type' => $ticket['biz_type'],
'biz_id' => (int)($ticket['biz_id'] ?? 0),
'order_id' => (int)($ticket['order_id'] ?? 0),
'user_id' => (int)($ticket['user_id'] ?? 0),
'status' => $ticket['status'],
'status_text' => $this->statusText($ticket['status']),
'priority' => $ticket['priority'],
'priority_text' => $this->priorityText($ticket['priority']),
'title' => $ticket['title'],
'content' => $ticket['content'],
'created_at' => $ticket['created_at'],
'updated_at' => $ticket['updated_at'],
],
'messages' => array_map(function (array $item) {
return [
'sender_type' => $item['sender_type'],
'sender_type_text' => $item['sender_type'] === 'customer_service' ? '客服' : ($item['sender_type'] === 'system' ? '系统' : '用户'),
'content' => $item['content'] ?: '',
'attachments' => $this->attachmentService()->normalize($item['attachments_json'] ?? null, $request),
'created_at' => $item['created_at'],
];
}, $messages),
]);
}
public function save(Request $request)
{
$id = (int)$request->input('id', 0);
if (!$id) {
return api_error('工单 ID 不能为空', 422);
}
$ticket = Db::name('tickets')->where('id', $id)->find();
if (!$ticket) {
return api_error('工单不存在', 404);
}
$status = trim((string)$request->input('status', $ticket['status']));
$priority = trim((string)$request->input('priority', $ticket['priority']));
$now = date('Y-m-d H:i:s');
Db::startTrans();
try {
Db::name('tickets')->where('id', $id)->update([
'status' => $status,
'priority' => $priority,
'updated_at' => $now,
]);
if ($status !== $ticket['status']) {
$this->notifyStatusChanged($ticket, $status, $now);
}
Db::commit();
} catch (\Throwable $e) {
Db::rollback();
return api_error('工单更新失败', 500, [
'detail' => $e->getMessage(),
]);
}
return api_success(['id' => $id], '工单已更新');
}
public function reply(Request $request)
{
$ticketId = (int)$request->input('ticket_id', 0);
$content = trim((string)$request->input('content', ''));
$attachments = $this->attachmentService()->normalize($request->input('attachments', []), $request, true);
if (!$ticketId) {
return api_error('工单 ID 不能为空', 422);
}
if ($content === '' && !$attachments) {
return api_error('回复内容和附件至少填写一项', 422);
}
$ticket = Db::name('tickets')->where('id', $ticketId)->find();
if (!$ticket) {
return api_error('工单不存在', 404);
}
$now = date('Y-m-d H:i:s');
Db::startTrans();
try {
$messageId = (int)Db::name('ticket_messages')->insertGetId([
'ticket_id' => $ticketId,
'sender_type' => 'customer_service',
'sender_id' => 1,
'content' => $content,
'attachments_json' => $attachments ? json_encode($attachments, JSON_UNESCAPED_UNICODE) : null,
'created_at' => $now,
]);
Db::name('tickets')->where('id', $ticketId)->update([
'status' => 'processing',
'updated_at' => $now,
]);
(new MessageDispatcher())->sendInboxEvent('ticket_reply', [
'user_id' => (int)($ticket['user_id'] ?? 0),
'biz_type' => 'ticket_message',
'biz_id' => $messageId,
'ticket_id' => $ticketId,
'ticket_no' => $ticket['ticket_no'],
'ticket_title' => $ticket['title'] ?: '客服工单',
'reply_content' => $content,
'fallback_title' => '工单有新回复',
'fallback_content' => sprintf('客服已回复您的工单「%s」点击查看详情。', $ticket['title'] ?: '客服工单'),
]);
Db::commit();
return api_success(['ticket_id' => $ticketId], '回复成功');
} catch (\Throwable $e) {
Db::rollback();
return api_error('回复失败', 500, [
'detail' => $e->getMessage(),
]);
}
}
public function uploadFile(Request $request)
{
try {
$asset = $this->attachmentService()->upload($request);
return api_success($asset);
} catch (\Throwable $e) {
return api_error($e->getMessage(), 422);
}
}
public function deleteFile(Request $request)
{
$fileUrl = trim((string)$request->input('file_url', ''));
if ($fileUrl === '') {
return api_error('文件地址不能为空', 422);
}
$this->attachmentService()->delete($fileUrl);
return api_success([
'file_url' => $fileUrl,
], '删除成功');
}
private function statusText(string $status): string
{
return (new ContentService())->ticketStatusText($status);
}
private function priorityText(string $priority): string
{
return match ($priority) {
'high' => '高优先级',
'normal' => '普通',
'low' => '低优先级',
default => $priority,
};
}
private function ticketTypeText(string $type): string
{
return (new ContentService())->ticketTypeText($type);
}
private function notifyStatusChanged(array $ticket, string $status, string $now): void
{
$eventConfig = match ($status) {
'waiting_user' => [
'event_code' => 'ticket_waiting_user',
'title' => '工单等待您补充反馈',
'content' => sprintf('客服正在跟进工单「%s」当前需要您补充反馈信息。', $ticket['title'] ?: '客服工单'),
'system_message' => '工单状态已更新为待用户反馈,请等待用户补充信息。',
],
'resolved' => [
'event_code' => 'ticket_resolved',
'title' => '工单已解决',
'content' => sprintf('您的工单「%s」已处理完成如仍有疑问可继续留言。', $ticket['title'] ?: '客服工单'),
'system_message' => '工单状态已更新为已解决。',
],
'closed' => [
'event_code' => 'ticket_closed',
'title' => '工单已关闭',
'content' => sprintf('您的工单「%s」已关闭如需继续处理可重新发起工单。', $ticket['title'] ?: '客服工单'),
'system_message' => '工单状态已更新为已关闭。',
],
default => null,
};
if (!$eventConfig) {
return;
}
$messageId = (int)Db::name('ticket_messages')->insertGetId([
'ticket_id' => (int)$ticket['id'],
'sender_type' => 'system',
'sender_id' => null,
'content' => $eventConfig['system_message'],
'attachments_json' => null,
'created_at' => $now,
]);
(new MessageDispatcher())->sendInboxEvent($eventConfig['event_code'], [
'user_id' => (int)($ticket['user_id'] ?? 0),
'biz_type' => 'ticket_message',
'biz_id' => $messageId,
'ticket_id' => (int)$ticket['id'],
'ticket_no' => $ticket['ticket_no'] ?? '',
'ticket_title' => $ticket['title'] ?: '客服工单',
'fallback_title' => $eventConfig['title'],
'fallback_content' => $eventConfig['content'],
]);
}
private function attachmentService(): TicketAttachmentService
{
return new TicketAttachmentService();
}
}

View File

@@ -0,0 +1,244 @@
<?php
namespace app\controller\admin;
use support\Request;
use support\think\Db;
class UsersController
{
public function overview(Request $request)
{
$this->ensurePasswordColumn();
return api_success([
'cards' => [
[
'title' => '用户总量',
'value' => (int)Db::name('users')->count(),
'desc' => '当前数据库中的用户数量',
],
[
'title' => '正常用户',
'value' => (int)Db::name('users')->where('status', 'enabled')->count(),
'desc' => '当前可正常使用系统的用户数量',
],
[
'title' => '地址数量',
'value' => (int)Db::name('user_addresses')->count(),
'desc' => '用户维护的寄送与收货地址总数',
],
[
'title' => '消息总量',
'value' => (int)Db::name('user_messages')->count(),
'desc' => '已发送给用户的站内消息数量',
],
],
]);
}
public function index(Request $request)
{
$this->ensurePasswordColumn();
$keyword = trim((string)$request->input('keyword', ''));
$status = trim((string)$request->input('status', ''));
$query = Db::name('users')
->alias('u')
->leftJoin('user_addresses a', 'a.user_id = u.id AND a.is_default = 1')
->field([
'u.id',
'u.nickname',
'u.mobile',
'u.password',
'u.status',
'u.created_at',
'u.updated_at',
'a.province',
'a.city',
'a.district',
'a.detail_address',
])
->order('u.id', 'desc');
if ($keyword !== '') {
$query->where(function ($builder) use ($keyword) {
$builder->whereLike('u.nickname', "%{$keyword}%")
->whereOrLike('u.mobile', "%{$keyword}%");
});
}
if ($status !== '') {
$query->where('u.status', $status);
}
$rows = $query->select()->toArray();
$list = array_map(function (array $item) {
$userId = (int)$item['id'];
return [
'id' => $userId,
'nickname' => $item['nickname'] ?: '未命名用户',
'mobile' => $item['mobile'] ?: '',
'status' => $item['status'],
'status_text' => $this->userStatusText($item['status']),
'password_set' => ((string)($item['password'] ?? '')) !== '',
'default_address' => trim(sprintf(
'%s%s%s%s',
$item['province'] ?? '',
$item['city'] ?? '',
$item['district'] ?? '',
$item['detail_address'] ?? ''
)),
'order_count' => (int)Db::name('orders')->where('user_id', $userId)->count(),
'message_count' => (int)Db::name('user_messages')->where('user_id', $userId)->count(),
'ticket_count' => (int)Db::name('tickets')->where('user_id', $userId)->count(),
'created_at' => $item['created_at'],
'updated_at' => $item['updated_at'],
];
}, $rows);
return api_success(['list' => $list]);
}
public function detail(Request $request)
{
$this->ensurePasswordColumn();
$id = (int)$request->input('id', 0);
if ($id <= 0) {
return api_error('用户 ID 不能为空', 422);
}
$user = Db::name('users')->where('id', $id)->find();
if (!$user) {
return api_error('用户不存在', 404);
}
$addresses = Db::name('user_addresses')
->where('user_id', $id)
->order('is_default', 'desc')
->order('id', 'desc')
->select()
->toArray();
$recentOrders = Db::name('orders')
->where('user_id', $id)
->order('id', 'desc')
->limit(5)
->select()
->toArray();
$recentMessages = Db::name('user_messages')
->where('user_id', $id)
->order('id', 'desc')
->limit(5)
->select()
->toArray();
return api_success([
'user_info' => [
'id' => (int)$user['id'],
'nickname' => $user['nickname'] ?: '未命名用户',
'mobile' => $user['mobile'] ?: '',
'status' => $user['status'],
'status_text' => $this->userStatusText($user['status']),
'password_set' => ((string)($user['password'] ?? '')) !== '',
'created_at' => $user['created_at'],
'updated_at' => $user['updated_at'],
],
'addresses' => array_map(fn (array $item) => [
'consignee' => $item['consignee'],
'mobile' => $item['mobile'],
'full_address' => trim(sprintf('%s%s%s%s', $item['province'], $item['city'], $item['district'], $item['detail_address'])),
'is_default' => (bool)$item['is_default'],
], $addresses),
'recent_orders' => array_map(fn (array $item) => [
'order_no' => $item['order_no'],
'display_status' => $item['display_status'],
'pay_amount' => (float)$item['pay_amount'],
'created_at' => $item['created_at'],
], $recentOrders),
'recent_messages' => array_map(fn (array $item) => [
'title' => $item['title'],
'content' => $item['content'],
'is_read' => (bool)$item['is_read'],
'created_at' => $item['created_at'],
], $recentMessages),
]);
}
public function save(Request $request)
{
$this->ensurePasswordColumn();
$id = (int)$request->input('id', 0);
$nickname = trim((string)$request->input('nickname', ''));
$mobile = trim((string)$request->input('mobile', ''));
$status = trim((string)$request->input('status', 'enabled'));
$password = trim((string)$request->input('password', ''));
if ($nickname === '' || $mobile === '') {
return api_error('昵称和手机号不能为空', 422);
}
$now = date('Y-m-d H:i:s');
$payload = [
'nickname' => $nickname,
'mobile' => $mobile,
'status' => $status !== '' ? $status : 'enabled',
'updated_at' => $now,
];
if ($password !== '') {
$payload['password'] = password_hash($password, PASSWORD_BCRYPT);
}
if ($id > 0) {
$user = Db::name('users')->where('id', $id)->find();
if (!$user) {
return api_error('用户不存在', 404);
}
$exists = Db::name('users')
->where('mobile', $mobile)
->where('id', '<>', $id)
->find();
if ($exists) {
return api_error('手机号已存在', 422);
}
Db::name('users')->where('id', $id)->update($payload);
return api_success(['id' => $id], '用户已更新');
}
$exists = Db::name('users')->where('mobile', $mobile)->find();
if ($exists) {
return api_error('手机号已存在', 422);
}
$payload['avatar'] = '';
$payload['password'] = $payload['password'] ?? '';
$payload['last_login_at'] = null;
$payload['created_at'] = $now;
$newId = (int)Db::name('users')->insertGetId($payload);
return api_success(['id' => $newId], '用户已创建');
}
private function userStatusText(string $status): string
{
return match ($status) {
'enabled' => '正常',
'disabled' => '已停用',
default => $status,
};
}
private function ensurePasswordColumn(): void
{
$column = Db::query("SHOW COLUMNS FROM users LIKE 'password'");
if ($column) {
return;
}
Db::execute("ALTER TABLE users ADD COLUMN password VARCHAR(255) NOT NULL DEFAULT '' AFTER mobile");
}
}

View File

@@ -0,0 +1,69 @@
<?php
namespace app\controller\admin;
use app\support\WarehouseService;
use support\Request;
class WarehousesController
{
public function overview(Request $request)
{
return api_success([
'cards' => $this->service()->overviewCards(),
]);
}
public function index(Request $request)
{
return api_success([
'list' => $this->service()->list(),
'category_options' => array_map(static fn(array $item) => [
'id' => (int)$item['id'],
'name' => $item['name'],
], \support\think\Db::name('catalog_categories')->where('is_enabled', 1)->order('sort_order', 'asc')->select()->toArray()),
]);
}
public function save(Request $request)
{
$id = (int)$request->input('id', 0);
try {
$warehouseId = $this->service()->save([
'warehouse_name' => $request->input('warehouse_name', ''),
'warehouse_code' => $request->input('warehouse_code', ''),
'service_provider' => $request->input('service_provider', 'anxinyan'),
'receiver_name' => $request->input('receiver_name', ''),
'receiver_mobile' => $request->input('receiver_mobile', ''),
'province' => $request->input('province', ''),
'city' => $request->input('city', ''),
'district' => $request->input('district', ''),
'detail_address' => $request->input('detail_address', ''),
'service_time' => $request->input('service_time', ''),
'notice' => $request->input('notice', ''),
'supported_category_ids' => $request->input('supported_category_ids', []),
'service_area_provinces' => $request->input('service_area_provinces', []),
'service_area_cities' => $request->input('service_area_cities', []),
'status' => $request->input('status', 'enabled'),
'is_default' => $request->input('is_default', false),
'sort_order' => $request->input('sort_order', 0),
'remark' => $request->input('remark', ''),
], $id);
} catch (\RuntimeException $e) {
return api_error($e->getMessage(), 422);
} catch (\Throwable $e) {
return api_error('仓库保存失败', 500, [
'detail' => $e->getMessage(),
]);
}
return api_success([
'id' => $warehouseId,
], $id > 0 ? '仓库已更新' : '仓库已创建');
}
private function service(): WarehouseService
{
return new WarehouseService();
}
}

View File

@@ -0,0 +1,219 @@
<?php
namespace app\controller\app;
use support\Request;
use support\think\Db;
class AddressesController
{
public function index(Request $request)
{
$userId = app_user_id($request);
$rows = Db::name('user_addresses')
->where('user_id', $userId)
->order('is_default', 'desc')
->order('id', 'desc')
->select()
->toArray();
return api_success([
'list' => array_map(fn (array $item) => $this->formatAddress($item), $rows),
]);
}
public function detail(Request $request)
{
$id = (int)$request->input('id', 0);
if ($id <= 0) {
return api_error('地址 ID 不能为空', 422);
}
$address = Db::name('user_addresses')->where('id', $id)->where('user_id', app_user_id($request))->find();
if (!$address) {
return api_error('地址不存在', 404);
}
return api_success($this->formatAddress($address));
}
public function save(Request $request)
{
$userId = app_user_id($request);
$id = (int)$request->input('id', 0);
$consignee = trim((string)$request->input('consignee', ''));
$mobile = trim((string)$request->input('mobile', ''));
$province = trim((string)$request->input('province', ''));
$city = trim((string)$request->input('city', ''));
$district = trim((string)$request->input('district', ''));
$detailAddress = trim((string)$request->input('detail_address', ''));
$isDefault = (bool)$request->input('is_default', false);
if ($consignee === '' || $mobile === '' || $province === '' || $city === '' || $district === '' || $detailAddress === '') {
return api_error('请完整填写地址信息', 422);
}
$now = date('Y-m-d H:i:s');
Db::startTrans();
try {
$existing = null;
if ($id > 0) {
$existing = Db::name('user_addresses')->where('id', $id)->where('user_id', $userId)->find();
if (!$existing) {
Db::rollback();
return api_error('地址不存在', 404);
}
}
$addressCount = (int)Db::name('user_addresses')->where('user_id', $userId)->count();
$shouldSetDefault = $isDefault || $addressCount === 0 || ($existing && (bool)$existing['is_default']);
if ($shouldSetDefault) {
Db::name('user_addresses')->where('user_id', $userId)->update([
'is_default' => 0,
'updated_at' => $now,
]);
}
$payload = [
'user_id' => $userId,
'consignee' => $consignee,
'mobile' => $mobile,
'province' => $province,
'city' => $city,
'district' => $district,
'detail_address' => $detailAddress,
'is_default' => $shouldSetDefault ? 1 : 0,
'updated_at' => $now,
];
if ($existing) {
Db::name('user_addresses')->where('id', $id)->update($payload);
$addressId = $id;
} else {
$payload['created_at'] = $now;
$addressId = (int)Db::name('user_addresses')->insertGetId($payload);
}
Db::commit();
} catch (\Throwable $e) {
Db::rollback();
return api_error('地址保存失败', 500, [
'detail' => $e->getMessage(),
]);
}
$address = Db::name('user_addresses')->where('id', $addressId)->where('user_id', $userId)->find();
return api_success([
'id' => $addressId,
'address' => $this->formatAddress($address ?: []),
], '地址已保存');
}
public function setDefault(Request $request)
{
$id = (int)$request->input('id', 0);
if ($id <= 0) {
return api_error('地址 ID 不能为空', 422);
}
$address = Db::name('user_addresses')->where('id', $id)->where('user_id', app_user_id($request))->find();
if (!$address) {
return api_error('地址不存在', 404);
}
$now = date('Y-m-d H:i:s');
Db::startTrans();
try {
Db::name('user_addresses')->where('user_id', app_user_id($request))->update([
'is_default' => 0,
'updated_at' => $now,
]);
Db::name('user_addresses')->where('id', $id)->where('user_id', app_user_id($request))->update([
'is_default' => 1,
'updated_at' => $now,
]);
Db::commit();
} catch (\Throwable $e) {
Db::rollback();
return api_error('默认地址设置失败', 500, [
'detail' => $e->getMessage(),
]);
}
return api_success([
'id' => $id,
], '已设为默认地址');
}
public function delete(Request $request)
{
$id = (int)$request->input('id', 0);
if ($id <= 0) {
return api_error('地址 ID 不能为空', 422);
}
$address = Db::name('user_addresses')->where('id', $id)->where('user_id', app_user_id($request))->find();
if (!$address) {
return api_error('地址不存在', 404);
}
Db::startTrans();
try {
Db::name('user_addresses')->where('id', $id)->where('user_id', app_user_id($request))->delete();
if ((bool)$address['is_default']) {
$next = Db::name('user_addresses')
->where('user_id', app_user_id($request))
->order('id', 'desc')
->find();
if ($next) {
Db::name('user_addresses')->where('id', $next['id'])->update([
'is_default' => 1,
'updated_at' => date('Y-m-d H:i:s'),
]);
}
}
Db::commit();
} catch (\Throwable $e) {
Db::rollback();
return api_error('地址删除失败', 500, [
'detail' => $e->getMessage(),
]);
}
return api_success([
'id' => $id,
], '地址已删除');
}
private function formatAddress(array $item): array
{
return [
'id' => (int)($item['id'] ?? 0),
'consignee' => $item['consignee'] ?? '',
'mobile' => $item['mobile'] ?? '',
'province' => $item['province'] ?? '',
'city' => $item['city'] ?? '',
'district' => $item['district'] ?? '',
'detail_address' => $item['detail_address'] ?? '',
'full_address' => trim(sprintf(
'%s%s%s%s',
$item['province'] ?? '',
$item['city'] ?? '',
$item['district'] ?? '',
$item['detail_address'] ?? ''
)),
'is_default' => (bool)($item['is_default'] ?? false),
'created_at' => $item['created_at'] ?? '',
'updated_at' => $item['updated_at'] ?? '',
];
}
}

View File

@@ -0,0 +1,663 @@
<?php
namespace app\controller\app;
use app\support\MessageDispatcher;
use app\support\ContentService;
use app\support\FileStorageService;
use app\support\PublicAssetUrlService;
use app\support\WarehouseService;
use support\Request;
use support\think\Db;
use function str_starts_with;
class AppraisalController
{
public function uploadFile(Request $request)
{
$userId = app_user_id($request);
$draftId = (int)$request->post('draft_id', 0);
$itemCode = trim((string)$request->post('item_code', ''));
$itemName = trim((string)$request->post('item_name', ''));
if (!$draftId || $itemCode === '') {
return api_error('草稿 ID 和资料项编码不能为空', 422);
}
$draft = Db::name('appraisal_drafts')->where('id', $draftId)->where('user_id', $userId)->find();
if (!$draft) {
return api_error('草稿不存在', 404);
}
$file = $request->file('file');
if (!$file || !$file->isValid()) {
return api_error('上传文件无效', 422);
}
$extension = strtolower($file->getUploadExtension() ?: 'jpg');
$filename = sprintf('%s_%s.%s', $itemCode, uniqid(), $extension);
$relativeDir = 'uploads/appraisal/' . date('Ymd');
$relativePath = $relativeDir . '/' . $filename;
$this->storage()->putUploadedFile($file, $relativePath);
$fileUrl = $this->storage()->publicUrl($request, $relativePath);
return api_success([
'file_id' => md5($relativePath),
'item_code' => $itemCode,
'item_name' => $itemName,
'file_url' => $fileUrl,
'thumbnail_url' => $fileUrl,
'name' => $file->getUploadName(),
]);
}
public function createDraft(Request $request)
{
$userId = app_user_id($request);
$serviceProvider = (string)$request->input('service_provider', 'anxinyan');
$serviceMode = (string)$request->input('service_mode', 'physical');
$draftId = Db::name('appraisal_drafts')->insertGetId([
'user_id' => $userId,
'service_mode' => $serviceMode,
'service_provider' => $serviceProvider,
'current_step' => 1,
'status' => 'draft',
'created_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s'),
]);
return api_success([
'draft_id' => (int)$draftId,
'service_provider' => $serviceProvider,
'service_mode' => $serviceMode,
]);
}
public function deleteFile(Request $request)
{
$fileUrl = trim((string)$request->post('file_url', ''));
if ($fileUrl === '') {
return api_error('文件地址不能为空', 422);
}
$relativePath = $this->storage()->storagePath($fileUrl);
if (!str_starts_with($relativePath, 'uploads/appraisal/')) {
return api_error('不允许删除该文件', 403);
}
$this->storage()->delete($relativePath);
return api_success([
'file_url' => $fileUrl,
], '删除成功');
}
public function draftDetail(Request $request)
{
$draftId = (int)$request->input('draft_id', 0);
$draft = Db::name('appraisal_drafts')->where('id', $draftId)->where('user_id', app_user_id($request))->find();
if (!$draft) {
return api_error('草稿不存在', 404);
}
$product = Db::name('appraisal_draft_products')->where('draft_id', $draftId)->find();
$extra = Db::name('appraisal_draft_extras')->where('draft_id', $draftId)->find();
return api_success([
'draft_id' => (int)$draft['id'],
'service_provider' => $draft['service_provider'],
'service_mode' => $draft['service_mode'],
'current_step' => (int)$draft['current_step'],
'product_info' => $product ?: new \stdClass(),
'extra_info' => $extra ?: new \stdClass(),
'upload_info' => [
'items' => $this->draftUploadItems($draftId, $request),
],
]);
}
public function saveDraft(Request $request)
{
$draftId = (int)$request->input('draft_id', 0);
$draft = Db::name('appraisal_drafts')->where('id', $draftId)->where('user_id', app_user_id($request))->find();
if (!$draft) {
return api_error('草稿不存在', 404);
}
$currentStep = (int)$request->input('current_step', $draft['current_step']);
$productInfo = (array)$request->input('product_info', []);
$extraInfo = (array)$request->input('extra_info', []);
$uploadInfo = (array)$request->input('upload_info', []);
Db::name('appraisal_drafts')
->where('id', $draftId)
->update([
'service_provider' => $request->input('service_provider', $draft['service_provider']),
'current_step' => $currentStep,
'updated_at' => date('Y-m-d H:i:s'),
]);
if ($productInfo) {
$payload = [
'draft_id' => $draftId,
'category_id' => $productInfo['category_id'] ?? null,
'brand_id' => $productInfo['brand_id'] ?? null,
'color' => $productInfo['color'] ?? '',
'size_spec' => $productInfo['size_spec'] ?? '',
'serial_no' => $productInfo['serial_no'] ?? '',
'updated_at' => date('Y-m-d H:i:s'),
];
$exists = Db::name('appraisal_draft_products')->where('draft_id', $draftId)->find();
if ($exists) {
Db::name('appraisal_draft_products')->where('draft_id', $draftId)->update($payload);
} else {
$payload['created_at'] = date('Y-m-d H:i:s');
Db::name('appraisal_draft_products')->insert($payload);
}
}
if ($extraInfo) {
$purchaseDate = $extraInfo['purchase_date'] ?? null;
if ($purchaseDate === '') {
$purchaseDate = null;
}
$payload = [
'draft_id' => $draftId,
'purchase_channel' => $extraInfo['purchase_channel'] ?? '',
'purchase_price' => $extraInfo['purchase_price'] ?? 0,
'purchase_date' => $purchaseDate,
'usage_status' => $extraInfo['usage_status'] ?? '',
'condition_desc' => $extraInfo['condition_desc'] ?? '',
'has_accessories' => !empty($extraInfo['accessories']) ? 1 : 0,
'accessories_json' => json_encode($extraInfo['accessories'] ?? [], JSON_UNESCAPED_UNICODE),
'remark' => $extraInfo['remark'] ?? '',
'updated_at' => date('Y-m-d H:i:s'),
];
$exists = Db::name('appraisal_draft_extras')->where('draft_id', $draftId)->find();
if ($exists) {
Db::name('appraisal_draft_extras')->where('draft_id', $draftId)->update($payload);
} else {
$payload['created_at'] = date('Y-m-d H:i:s');
Db::name('appraisal_draft_extras')->insert($payload);
}
}
if ($uploadInfo) {
$draftUploadIds = Db::name('appraisal_draft_uploads')->where('draft_id', $draftId)->column('id');
if ($draftUploadIds) {
Db::name('appraisal_draft_upload_files')->whereIn('draft_upload_id', $draftUploadIds)->delete();
}
Db::name('appraisal_draft_uploads')->where('draft_id', $draftId)->delete();
foreach (($uploadInfo['items'] ?? []) as $item) {
$draftUploadId = Db::name('appraisal_draft_uploads')->insertGetId([
'draft_id' => $draftId,
'template_id' => $uploadInfo['template_id'] ?? null,
'item_code' => $item['item_code'] ?? '',
'item_name' => $item['item_name'] ?? '',
'is_required' => !empty($item['is_required']) ? 1 : 0,
'quality_status' => $item['quality_status'] ?? 'pending',
'quality_message' => $item['quality_message'] ?? '',
'created_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s'),
]);
foreach (($item['files'] ?? []) as $index => $file) {
Db::name('appraisal_draft_upload_files')->insert([
'draft_upload_id' => $draftUploadId,
'file_id' => $file['file_id'] ?? '',
'file_url' => $this->assetUrlService()->storagePath((string)($file['file_url'] ?? '')),
'thumbnail_url' => $this->assetUrlService()->storagePath((string)($file['thumbnail_url'] ?? ($file['file_url'] ?? ''))),
'sort_order' => $index,
'created_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s'),
]);
}
}
}
return api_success(['draft_id' => $draftId, 'current_step' => $currentStep]);
}
public function uploadTemplate(Request $request)
{
$categoryId = (int)$request->input('category_id', 1);
$serviceProvider = (string)$request->input('service_provider', 'anxinyan');
$template = Db::name('upload_templates')
->where('scope_type', 'category')
->where('scope_id', $categoryId)
->where('service_provider', $serviceProvider)
->where('is_enabled', 1)
->find();
if (!$template) {
$template = Db::name('upload_templates')
->where('scope_type', 'category')
->where('scope_id', $categoryId)
->where('service_provider', 'anxinyan')
->where('is_enabled', 1)
->find();
}
if (!$template) {
return api_success([
'template_id' => 0,
'required_items' => [],
'optional_items' => [],
]);
}
$items = Db::name('upload_template_items')
->where('template_id', $template['id'])
->where('is_enabled', 1)
->order('sort_order', 'asc')
->select()
->toArray();
$requiredItems = [];
$optionalItems = [];
foreach ($items as $item) {
$payload = [
'item_code' => $item['item_code'],
'item_name' => $item['item_name'],
'guide_text' => $item['guide_text'],
'sample_image_url' => $this->assetUrlService()->normalizeUrl((string)$item['sample_image_url'], $request),
'is_required' => (bool)$item['is_required'],
'quality_status' => $item['is_required'] ? 'pending' : 'optional',
'quality_message' => '',
];
if ($item['is_required']) {
$requiredItems[] = $payload;
} else {
$optionalItems[] = $payload;
}
}
return api_success([
'template_id' => (int)$template['id'],
'required_items' => $requiredItems,
'optional_items' => $optionalItems,
]);
}
public function preview(Request $request)
{
$draftId = (int)$request->input('draft_id', 0);
$draft = Db::name('appraisal_drafts')->where('id', $draftId)->where('user_id', app_user_id($request))->find();
$product = Db::name('appraisal_draft_products')->where('draft_id', $draftId)->find();
$extra = Db::name('appraisal_draft_extras')->where('draft_id', $draftId)->find();
if (!$draft) {
return api_error('预览数据不存在', 404);
}
$serviceConfig = $this->serviceConfig((string)$draft['service_provider']);
$policyConfig = (new ContentService())->getPolicyConfig();
return api_success([
'service_summary' => [
'service_provider' => $draft['service_provider'],
'service_provider_text' => $draft['service_provider'] === 'zhongjian' ? '中检鉴定' : '实物鉴定',
],
'product_summary' => [
'product_name' => $this->resolveProductName($product),
'category_name' => $this->lookupName('catalog_categories', 'name', $product['category_id'] ?? null),
'brand_name' => $this->lookupName('catalog_brands', 'name', $product['brand_id'] ?? null),
'price' => $extra['purchase_price'] ?? 0,
],
'upload_summary' => [
'uploaded_count' => $this->countUploadedDraftItems($draftId),
],
'fee_detail' => [
'service_fee' => (float)$serviceConfig['price'],
'discount_fee' => 0,
'pay_amount' => (float)$serviceConfig['price'],
],
'agreements' => $policyConfig['appraisal_agreements'],
]);
}
public function submit(Request $request)
{
$userId = app_user_id($request);
$draftId = (int)$request->input('draft_id', 0);
$returnAddressId = (int)$request->input('return_address_id', 0);
$sourceChannel = $this->normalizeOrderSourceChannel((string)$request->input('source_channel', 'mini_program'));
$sourceCustomerId = trim((string)$request->input('source_customer_id', ''));
if ($sourceChannel === 'enterprise_push' && $sourceCustomerId === '') {
return api_error('大客户推送订单必须提供客户 ID', 422);
}
if ($sourceChannel !== 'enterprise_push') {
$sourceCustomerId = '';
}
$draft = Db::name('appraisal_drafts')->where('id', $draftId)->where('user_id', $userId)->find();
$product = Db::name('appraisal_draft_products')->where('draft_id', $draftId)->find();
$extra = Db::name('appraisal_draft_extras')->where('draft_id', $draftId)->find();
if (!$draft || !$product) {
return api_error('提交数据不完整', 422);
}
$serviceConfig = $this->serviceConfig((string)$draft['service_provider']);
$now = date('Y-m-d H:i:s');
$orderNo = 'AXY' . date('YmdHis') . mt_rand(100, 999);
$appraisalNo = 'AXY-APP-' . date('Ymd') . '-' . mt_rand(1000, 9999);
$estimated = date('Y-m-d H:i:s', strtotime(sprintf('+%d hours', (int)$serviceConfig['sla_hours'])));
$productName = $this->resolveProductName($product);
$warehouseService = new WarehouseService();
$defaultAddress = Db::name('user_addresses')
->where('user_id', $userId)
->where('is_default', 1)
->find();
$returnAddress = null;
if ($returnAddressId > 0) {
$returnAddress = Db::name('user_addresses')
->where('id', $returnAddressId)
->where('user_id', $userId)
->find();
if (!$returnAddress) {
return api_error('寄回地址不存在,请重新选择', 422);
}
}
if (!$returnAddress) {
$returnAddress = $defaultAddress ?: Db::name('user_addresses')
->where('user_id', $userId)
->order('id', 'desc')
->find();
}
if (!$returnAddress) {
return api_error('请先添加并确认寄回地址', 422);
}
Db::startTrans();
try {
$orderId = Db::name('orders')->insertGetId([
'order_no' => $orderNo,
'appraisal_no' => $appraisalNo,
'user_id' => $userId,
'service_mode' => $draft['service_mode'],
'service_provider' => $draft['service_provider'],
'payment_status' => 'paid',
'order_status' => 'pending_shipping',
'display_status' => '待寄送商品',
'estimated_finish_time' => $estimated,
'source_channel' => $sourceChannel,
'source_customer_id' => $sourceCustomerId,
'pay_amount' => $serviceConfig['price'],
'paid_at' => $now,
'created_at' => $now,
'updated_at' => $now,
]);
Db::name('order_products')->insert([
'order_id' => $orderId,
'category_id' => $product['category_id'] ?? null,
'category_name' => $this->lookupName('catalog_categories', 'name', $product['category_id'] ?? null),
'brand_id' => $product['brand_id'] ?? null,
'brand_name' => $this->lookupName('catalog_brands', 'name', $product['brand_id'] ?? null),
'color' => $product['color'] ?? '',
'size_spec' => $product['size_spec'] ?? '',
'serial_no' => $product['serial_no'] ?? '',
'product_name' => $productName,
'product_cover' => '',
'created_at' => $now,
'updated_at' => $now,
]);
Db::name('order_extras')->insert([
'order_id' => $orderId,
'purchase_channel' => $extra['purchase_channel'] ?? '',
'purchase_price' => $extra['purchase_price'] ?? 0,
'purchase_date' => $extra['purchase_date'] ?? null,
'usage_status' => $extra['usage_status'] ?? '',
'condition_desc' => $extra['condition_desc'] ?? '',
'has_accessories' => $extra['has_accessories'] ?? 0,
'accessories_json' => $extra['accessories_json'] ?? json_encode([], JSON_UNESCAPED_UNICODE),
'remark' => $extra['remark'] ?? '',
'created_at' => $now,
'updated_at' => $now,
]);
if ($returnAddress) {
Db::name('order_return_addresses')->insert([
'order_id' => $orderId,
'user_address_id' => (int)$returnAddress['id'],
'consignee' => $returnAddress['consignee'] ?? '',
'mobile' => $returnAddress['mobile'] ?? '',
'province' => $returnAddress['province'] ?? '',
'city' => $returnAddress['city'] ?? '',
'district' => $returnAddress['district'] ?? '',
'detail_address' => $returnAddress['detail_address'] ?? '',
'created_at' => $now,
'updated_at' => $now,
]);
}
$shippingTarget = $warehouseService->bindOrderTarget(
$orderId,
(string)$draft['service_provider'],
!empty($product['category_id']) ? (int)$product['category_id'] : null,
$defaultAddress ?: null
);
Db::name('order_timelines')->insertAll([
[
'order_id' => $orderId,
'node_code' => 'created',
'node_text' => '下单成功',
'node_desc' => '订单已生成并完成支付',
'operator_type' => 'system',
'operator_id' => null,
'occurred_at' => $now,
'created_at' => $now,
],
[
'order_id' => $orderId,
'node_code' => 'pending_shipping',
'node_text' => '待寄送商品',
'node_desc' => sprintf(
'请尽快将商品寄送至%s以免影响处理时效',
$shippingTarget['warehouse_name'] ?: '鉴定中心'
),
'operator_type' => 'system',
'operator_id' => null,
'occurred_at' => $now,
'created_at' => $now,
],
]);
$draftUploads = Db::name('appraisal_draft_uploads')->where('draft_id', $draftId)->select()->toArray();
foreach ($draftUploads as $draftUpload) {
$draftFiles = Db::name('appraisal_draft_upload_files')->where('draft_upload_id', $draftUpload['id'])->select()->toArray();
if (!$draftFiles) {
continue;
}
$orderUploadId = Db::name('order_upload_items')->insertGetId([
'order_id' => $orderId,
'template_id' => $draftUpload['template_id'],
'item_code' => $draftUpload['item_code'],
'item_name' => $draftUpload['item_name'],
'is_required' => $draftUpload['is_required'],
'source_type' => 'initial',
'status' => $draftUpload['quality_status'],
'created_at' => $now,
'updated_at' => $now,
]);
foreach ($draftFiles as $draftFile) {
Db::name('order_upload_files')->insert([
'order_upload_item_id' => $orderUploadId,
'file_id' => $draftFile['file_id'],
'file_url' => $draftFile['file_url'],
'thumbnail_url' => $draftFile['thumbnail_url'],
'quality_status' => $draftUpload['quality_status'],
'quality_message' => $draftUpload['quality_message'],
'uploaded_by_user_id' => $userId,
'created_at' => $now,
'updated_at' => $now,
]);
}
}
Db::name('appraisal_tasks')->insert([
'order_id' => $orderId,
'task_stage' => 'first_review',
'service_provider' => $draft['service_provider'],
'status' => 'pending',
'assignee_id' => null,
'assignee_name' => '未分配',
'started_at' => null,
'submitted_at' => null,
'sla_deadline' => $estimated,
'is_overtime' => 0,
'created_at' => $now,
'updated_at' => $now,
]);
Db::name('appraisal_drafts')->where('id', $draftId)->update([
'status' => 'submitted',
'updated_at' => $now,
]);
(new MessageDispatcher())->sendInboxEvent('order_created', [
'user_id' => $userId,
'biz_type' => 'order',
'biz_id' => (int)$orderId,
'order_no' => $orderNo,
'appraisal_no' => $appraisalNo,
'product_name' => $productName,
'pay_amount' => (string)$serviceConfig['price'],
'fallback_title' => '订单提交成功',
'fallback_content' => '您的鉴定订单已提交成功,可前往订单中心查看进度。',
]);
Db::commit();
} catch (\Throwable $e) {
Db::rollback();
return api_error('提交失败,请稍后重试', 500, [
'detail' => $e->getMessage(),
]);
}
return api_success([
'order_id' => (int)$orderId,
'order_no' => $orderNo,
'appraisal_no' => $appraisalNo,
'pay_amount' => (float)$serviceConfig['price'],
'next_status' => 'pending_shipping',
]);
}
private function lookupName(string $table, string $field, mixed $id): string
{
if (empty($id)) {
return '';
}
return (string)Db::name($table)->where('id', $id)->value($field);
}
private function resolveProductName(?array $product): string
{
if (!$product) {
return '';
}
$categoryName = $this->lookupName('catalog_categories', 'name', $product['category_id'] ?? null);
$brandName = $this->lookupName('catalog_brands', 'name', $product['brand_id'] ?? null);
$fallbackName = trim($categoryName . ' ' . $brandName);
if ($fallbackName !== '') {
return $fallbackName;
}
return '';
}
private function serviceConfig(string $serviceProvider): array
{
$configs = [
'anxinyan' => ['price' => 99.00, 'sla_hours' => 48],
'zhongjian' => ['price' => 199.00, 'sla_hours' => 72],
];
if (isset($configs[$serviceProvider])) {
return $configs[$serviceProvider];
}
return $configs['anxinyan'];
}
private function draftUploadItems(int $draftId, Request $request): array
{
$uploads = Db::name('appraisal_draft_uploads')->where('draft_id', $draftId)->select()->toArray();
if (!$uploads) {
return [];
}
return array_map(function (array $item) use ($request) {
$files = Db::name('appraisal_draft_upload_files')
->where('draft_upload_id', $item['id'])
->order('sort_order', 'asc')
->select()
->toArray();
return [
'item_code' => $item['item_code'],
'item_name' => $item['item_name'],
'is_required' => (bool)$item['is_required'],
'quality_status' => $item['quality_status'],
'quality_message' => $item['quality_message'],
'files' => array_map(fn (array $file) => [
'file_id' => $file['file_id'],
'file_url' => $this->assetUrlService()->normalizeUrl((string)$file['file_url'], $request),
'thumbnail_url' => $this->assetUrlService()->normalizeUrl((string)$file['thumbnail_url'], $request),
], $files),
];
}, $uploads);
}
private function countUploadedDraftItems(int $draftId): int
{
$uploadIds = Db::name('appraisal_draft_upload_files')
->alias('f')
->join('appraisal_draft_uploads u', 'u.id = f.draft_upload_id')
->where('u.draft_id', $draftId)
->group('u.id')
->column('u.id');
return count($uploadIds);
}
private function assetUrlService(): PublicAssetUrlService
{
return new PublicAssetUrlService();
}
private function storage(): FileStorageService
{
return new FileStorageService();
}
private function normalizeOrderSourceChannel(string $sourceChannel): string
{
$sourceChannel = trim($sourceChannel);
$aliases = [
'wechat_mini_program' => 'mini_program',
'weixin_mini_program' => 'mini_program',
'mp_weixin' => 'mini_program',
'miniapp' => 'mini_program',
'user_app' => 'mini_program',
'web_h5' => 'h5',
'enterprise' => 'enterprise_push',
'enterprise_order' => 'enterprise_push',
'customer_push' => 'enterprise_push',
'large_customer_push' => 'enterprise_push',
];
$sourceChannel = $aliases[$sourceChannel] ?? $sourceChannel;
return in_array($sourceChannel, ['mini_program', 'h5', 'enterprise_push'], true)
? $sourceChannel
: 'mini_program';
}
}

View File

@@ -0,0 +1,112 @@
<?php
namespace app\controller\app;
use app\support\AppAuthService;
use support\Request;
class AuthController
{
public function sendCode(Request $request)
{
$mobile = trim((string)$request->input('mobile', ''));
if ($mobile === '') {
return api_error('手机号不能为空', 422);
}
try {
$payload = (new AppAuthService())->sendLoginCode($mobile, $request);
return api_success($payload, '验证码已发送');
} catch (\RuntimeException $e) {
return api_error($e->getMessage(), 422);
} catch (\Throwable $e) {
return api_error('验证码发送失败', 500, [
'detail' => $e->getMessage(),
]);
}
}
public function loginByCode(Request $request)
{
$mobile = trim((string)$request->input('mobile', ''));
$code = trim((string)$request->input('code', ''));
if ($mobile === '' || $code === '') {
return api_error('手机号和验证码不能为空', 422);
}
try {
$payload = (new AppAuthService())->loginByCode($mobile, $code, $request);
return api_success($payload, '登录成功');
} catch (\RuntimeException $e) {
return api_error($e->getMessage(), 401);
} catch (\Throwable $e) {
return api_error('登录失败', 500, [
'detail' => $e->getMessage(),
]);
}
}
public function loginByPassword(Request $request)
{
$mobile = trim((string)$request->input('mobile', ''));
$password = trim((string)$request->input('password', ''));
if ($mobile === '' || $password === '') {
return api_error('手机号和密码不能为空', 422);
}
try {
$payload = (new AppAuthService())->loginByPassword($mobile, $password, $request);
return api_success($payload, '登录成功');
} catch (\RuntimeException $e) {
return api_error($e->getMessage(), 401);
} catch (\Throwable $e) {
return api_error('登录失败', 500, [
'detail' => $e->getMessage(),
]);
}
}
public function me(Request $request)
{
$userInfo = (new AppAuthService())->current($request);
if (!$userInfo) {
return api_error('未登录或登录已过期', 401);
}
return api_success([
'user_info' => $userInfo,
]);
}
public function savePassword(Request $request)
{
$userId = app_user_id($request);
$currentPassword = trim((string)$request->input('current_password', ''));
$newPassword = trim((string)$request->input('new_password', ''));
$confirmPassword = trim((string)$request->input('confirm_password', ''));
if ($newPassword === '' || $confirmPassword === '') {
return api_error('新密码和确认密码不能为空', 422);
}
if ($newPassword !== $confirmPassword) {
return api_error('两次输入的新密码不一致', 422);
}
try {
$payload = (new AppAuthService())->savePassword($userId, $currentPassword, $newPassword);
return api_success($payload, !empty($payload['had_password']) ? '登录密码已更新' : '登录密码已设置');
} catch (\RuntimeException $e) {
return api_error($e->getMessage(), 422);
} catch (\Throwable $e) {
return api_error('密码保存失败', 500, [
'detail' => $e->getMessage(),
]);
}
}
public function logout(Request $request)
{
(new AppAuthService())->logout($request);
return api_success([], '已退出登录');
}
}

View File

@@ -0,0 +1,42 @@
<?php
namespace app\controller\app;
use support\Request;
use support\think\Db;
class CatalogController
{
public function categories(Request $request)
{
$list = Db::name('catalog_categories')
->field(['id AS category_id', 'name AS category_name', 'code AS category_code'])
->where('is_enabled', 1)
->order('sort_order', 'asc')
->select()
->toArray();
return api_success(['list' => $list]);
}
public function brands(Request $request)
{
$categoryId = (int)$request->input('category_id', 0);
$query = Db::name('catalog_brands')
->alias('b')
->field(['b.id AS brand_id', 'b.name AS brand_name', 'b.en_name AS brand_en_name'])
->where('b.is_enabled', 1)
->order('b.sort_order', 'asc');
if ($categoryId > 0) {
$query->join('catalog_brand_categories cbc', 'cbc.brand_id = b.id')
->where('cbc.category_id', $categoryId);
}
return api_success([
'list' => $query->select()->toArray(),
]);
}
}

View File

@@ -0,0 +1,101 @@
<?php
namespace app\controller\app;
use app\support\ContentService;
use support\Request;
class HelpCenterController
{
public function index(Request $request)
{
$keyword = trim((string)$request->input('q', ''));
$category = trim((string)$request->input('category', ''));
$articles = $this->articles();
$allArticles = $this->articles();
$categoryConfig = (new ContentService())->getHelpCategories();
if ($keyword !== '') {
$articles = array_values(array_filter($articles, function (array $item) use ($keyword) {
$haystack = implode(' ', array_merge(
[$item['title'], $item['summary'], $item['category_text']],
$item['keywords'],
$item['content_blocks']
));
return str_contains($haystack, $keyword);
}));
}
if ($category !== '' && $category !== 'all') {
$articles = array_values(array_filter($articles, fn (array $item) => $item['category'] === $category));
}
$categoryCounts = [];
foreach ($allArticles as $item) {
$categoryCounts[$item['category']] = ($categoryCounts[$item['category']] ?? 0) + 1;
}
$categories = array_map(function (array $item) use ($allArticles, $categoryCounts) {
$code = (string)($item['code'] ?? '');
return [
'code' => $code,
'title' => (string)($item['title'] ?? $code),
'desc' => (string)($item['desc'] ?? ''),
'count' => $code === 'all' ? count($allArticles) : (int)($categoryCounts[$code] ?? 0),
];
}, $categoryConfig);
return api_success([
'categories' => $categories,
'articles' => array_map(fn (array $item) => $this->articleSummary($item), $articles),
]);
}
public function detail(Request $request)
{
$id = (int)$request->input('id', 0);
if ($id <= 0) {
return api_error('文章 ID 不能为空', 422);
}
$article = null;
foreach ($this->articles() as $item) {
if ((int)$item['id'] === $id) {
$article = $item;
break;
}
}
if (!$article) {
return api_error('帮助文章不存在', 404);
}
$related = array_values(array_filter($this->articles(), fn (array $item) => $item['category'] === $article['category'] && $item['id'] !== $article['id']));
return api_success([
'article' => $article,
'related_articles' => array_map(fn (array $item) => $this->articleSummary($item), array_slice($related, 0, 3)),
]);
}
private function articleSummary(array $item): array
{
return [
'id' => (int)$item['id'],
'title' => $item['title'],
'category' => $item['category'],
'category_text' => $item['category_text'],
'summary' => $item['summary'],
'keywords' => $item['keywords'],
'updated_at' => $item['updated_at'],
'is_recommended' => (bool)$item['is_recommended'],
];
}
private function articles(): array
{
return (new ContentService())->getHelpArticles(true);
}
}

View File

@@ -0,0 +1,94 @@
<?php
namespace app\controller\app;
use app\model\CatalogCategory;
use app\support\ContentService;
use app\support\FileStorageService;
use support\Request;
class HomeController
{
public function index(Request $request)
{
$content = (new ContentService())->getHomeConfig();
$categoryVisuals = $this->categoryVisualMap($content['category_visuals'] ?? [], $request);
$categories = CatalogCategory::where('is_enabled', 1)
->order('sort_order', 'asc')
->field(['id', 'name', 'code'])
->select()
->map(function ($item) use ($categoryVisuals) {
$code = (string)$item->code;
$name = (string)$item->name;
$codeKey = $this->categoryMatchKey($code);
$nameKey = $this->categoryMatchKey($name);
return [
'category_id' => (int)$item->id,
'category_name' => $name,
'category_code' => $code,
'image_url' => $categoryVisuals['code:' . $codeKey] ?? $categoryVisuals['name:' . $nameKey] ?? '',
];
})
->toArray();
return api_success([
'banners' => $content['banners'],
'page_visuals' => $content['page_visuals'],
'service_entries' => $content['service_entries'],
'category_entries' => $categories,
'quick_entries' => $content['quick_entries'],
'trust_metrics' => $content['trust_metrics'],
'trust_points' => $content['trust_points'],
'faqs' => $content['faqs'],
]);
}
public function pageVisuals(Request $request)
{
$content = (new ContentService())->getHomeConfig();
return api_success($content['page_visuals'] ?? [
'order_background_image_url' => '',
'report_background_image_url' => '',
]);
}
private function categoryVisualMap(array $items, Request $request): array
{
$map = [];
$storage = new FileStorageService();
foreach ($items as $item) {
if (!is_array($item)) {
continue;
}
$imageUrl = trim((string)($item['image_url'] ?? ''));
if ($imageUrl === '') {
continue;
}
$imageUrl = $storage->normalizeUrl($imageUrl, $request);
$categoryCode = $this->categoryMatchKey((string)($item['category_code'] ?? ''));
if ($categoryCode !== '') {
$map['code:' . $categoryCode] = $imageUrl;
}
$categoryName = $this->categoryMatchKey((string)($item['category_name'] ?? ''));
if ($categoryName !== '') {
$map['name:' . $categoryName] = $imageUrl;
}
}
return $map;
}
private function categoryMatchKey(string $value): string
{
$value = trim($value);
$normalized = preg_replace('/[\s\p{Cf}]+/u', '', $value);
return strtolower($normalized ?? $value);
}
}

View File

@@ -0,0 +1,49 @@
<?php
namespace app\controller\app;
use app\support\MaterialTagService;
use support\Request;
class MaterialTagsController
{
public function show(Request $request)
{
$token = trim((string)$request->input('token', ''));
if ($token === '') {
return api_error('吊牌标识不能为空', 422);
}
try {
return api_success($this->service()->showPublicTag($token, $request));
} catch (\RuntimeException $e) {
return api_error($e->getMessage(), $e->getCode() ?: 404, [
'tag_status' => 'not_found',
]);
}
}
public function verify(Request $request)
{
$token = trim((string)$request->input('token', ''));
$reportNo = trim((string)$request->input('report_no', ''));
$verifyCode = trim((string)$request->input('verify_code', ''));
if ($token === '' || $reportNo === '' || $verifyCode === '') {
return api_error('吊牌标识、报告编号和验真编码不能为空', 422);
}
try {
return api_success($this->service()->verifyPublicTag($token, $reportNo, $verifyCode, $request));
} catch (\RuntimeException $e) {
return api_error($e->getMessage(), $e->getCode() ?: 404, [
'verify_passed' => false,
]);
}
}
private function service(): MaterialTagService
{
return new MaterialTagService();
}
}

View File

@@ -0,0 +1,277 @@
<?php
namespace app\controller\app;
use app\support\ContentService;
use support\Request;
use support\think\Db;
class MessagesController
{
public function summary(Request $request)
{
$userId = app_user_id($request);
$rows = Db::name('user_messages')
->where('user_id', $userId)
->order('id', 'desc')
->select()
->toArray();
$latest = $rows[0] ?? null;
$summary = $this->buildSummary($rows);
return api_success([
'total_count' => $summary['total_count'],
'unread_count' => $summary['unread_count'],
'category_counts' => $summary['category_counts'],
'latest_title' => $latest['title'] ?? '',
'latest_time' => $latest['created_at'] ?? '',
]);
}
public function meta(Request $request)
{
$content = new ContentService();
return api_success([
'message_page_copy' => $content->getMessagePageCopy(),
]);
}
public function index(Request $request)
{
$userId = app_user_id($request);
$category = $this->normalizeCategory((string)$request->input('category', 'all'));
$unreadOnly = (bool)$request->input('unread_only', false);
$rows = Db::name('user_messages')
->where('user_id', $userId)
->order('id', 'desc')
->select()
->toArray();
$filteredRows = array_values(array_filter($rows, function (array $item) use ($category, $unreadOnly) {
if ($unreadOnly && (bool)$item['is_read']) {
return false;
}
if ($category !== 'all' && $this->messageCategory($item['biz_type'] ?? '') !== $category) {
return false;
}
return true;
}));
$list = array_map(function (array $item) {
[$targetUrl, $targetLabel] = $this->resolveMessageTarget($item);
$messageCategory = $this->messageCategory($item['biz_type']);
return [
'id' => (int)$item['id'],
'title' => $item['title'],
'content' => $item['content'] ?: '',
'biz_type' => $item['biz_type'],
'biz_type_text' => $this->bizTypeText($item['biz_type']),
'category' => $messageCategory,
'category_text' => $this->categoryText($messageCategory),
'biz_id' => (int)($item['biz_id'] ?? 0),
'is_read' => (bool)$item['is_read'],
'created_at' => $item['created_at'],
'target_url' => $targetUrl,
'target_label' => $targetLabel,
];
}, $filteredRows);
$summary = $this->buildSummary($rows);
return api_success([
'list' => $list,
'summary' => array_merge($summary, [
'current_count' => count($list),
'current_category' => $category,
'unread_only' => $unreadOnly,
]),
]);
}
public function read(Request $request)
{
$id = (int)$request->input('id', 0);
if ($id <= 0) {
return api_error('消息 ID 不能为空', 422);
}
$message = Db::name('user_messages')->where('id', $id)->where('user_id', app_user_id($request))->find();
if (!$message) {
return api_error('消息不存在', 404);
}
if (!(bool)$message['is_read']) {
$now = date('Y-m-d H:i:s');
Db::name('user_messages')->where('id', $id)->update([
'is_read' => 1,
'read_at' => $now,
'updated_at' => $now,
]);
}
return api_success([
'id' => $id,
'is_read' => true,
], '已标记为已读');
}
public function readAll(Request $request)
{
$userId = app_user_id($request);
$now = date('Y-m-d H:i:s');
$affected = Db::name('user_messages')
->where('user_id', $userId)
->where('is_read', 0)
->update([
'is_read' => 1,
'read_at' => $now,
'updated_at' => $now,
]);
return api_success([
'affected' => (int)$affected,
], '已全部标记为已读');
}
private function resolveMessageTarget(array $message): array
{
$bizType = $message['biz_type'] ?? '';
$bizId = (int)($message['biz_id'] ?? 0);
if ($bizType === 'report' && $bizId > 0) {
$report = Db::name('reports')->where('id', $bizId)->find();
if ($report) {
return ["/pages/report/detail?report_no=" . rawurlencode((string)$report['report_no']), '查看报告'];
}
}
if ($bizType === 'order' && $bizId > 0) {
$order = Db::name('orders')->where('id', $bizId)->find();
if ($order) {
return ["/pages/order/detail?id={$bizId}", '查看订单'];
}
}
if ($bizType === 'return_shipped' && $bizId > 0) {
$order = Db::name('orders')->where('id', $bizId)->find();
if ($order) {
return ["/pages/order/detail?id={$bizId}", '查看物流'];
}
}
if ($bizType === 'return_received' && $bizId > 0) {
$order = Db::name('orders')->where('id', $bizId)->find();
if ($order) {
return ["/pages/order/detail?id={$bizId}", '查看订单'];
}
}
if ($bizType === 'supplement' && $bizId > 0) {
$supplementTask = Db::name('order_supplement_tasks')->where('id', $bizId)->find();
if ($supplementTask) {
if (($supplementTask['status'] ?? '') === 'pending') {
return ["/pages/order/supplement?order_id={$supplementTask['order_id']}", '去补资料'];
}
return ["/pages/order/detail?id={$supplementTask['order_id']}", '查看进度'];
}
}
if ($bizType === 'ticket_message' && $bizId > 0) {
$ticketMessage = Db::name('ticket_messages')->where('id', $bizId)->find();
if ($ticketMessage) {
return ["/pages/support/detail?id={$ticketMessage['ticket_id']}", '查看工单'];
}
}
if (in_array($bizType, ['ticket_waiting_user', 'ticket_resolved', 'ticket_closed'], true) && $bizId > 0) {
$ticket = Db::name('tickets')->where('id', $bizId)->find();
if ($ticket) {
return ["/pages/support/detail?id={$ticket['id']}", '查看工单'];
}
}
return ['', '查看详情'];
}
private function bizTypeText(string $bizType): string
{
return match ($bizType) {
'report' => '报告通知',
'order' => '订单通知',
'return_shipped' => '回寄通知',
'return_received' => '签收通知',
'supplement' => '补资料通知',
'ticket_message' => '工单通知',
'ticket_waiting_user' => '工单通知',
'ticket_resolved' => '工单通知',
'ticket_closed' => '工单通知',
default => '系统通知',
};
}
private function buildSummary(array $rows): array
{
$categoryCounts = [
'all' => count($rows),
'order' => 0,
'report' => 0,
'supplement' => 0,
'ticket' => 0,
];
$unreadCount = 0;
foreach ($rows as $item) {
$category = $this->messageCategory($item['biz_type'] ?? '');
if ($category !== 'all' && isset($categoryCounts[$category])) {
$categoryCounts[$category]++;
}
if (!(bool)($item['is_read'] ?? false)) {
$unreadCount++;
}
}
return [
'total_count' => count($rows),
'unread_count' => $unreadCount,
'category_counts' => $categoryCounts,
];
}
private function normalizeCategory(string $category): string
{
return in_array($category, ['all', 'order', 'report', 'supplement', 'ticket'], true)
? $category
: 'all';
}
private function messageCategory(string $bizType): string
{
return match ($bizType) {
'order' => 'order',
'return_shipped' => 'order',
'return_received' => 'order',
'report' => 'report',
'supplement' => 'supplement',
'ticket_message', 'ticket_waiting_user', 'ticket_resolved', 'ticket_closed' => 'ticket',
default => 'all',
};
}
private function categoryText(string $category): string
{
return match ($category) {
'order' => '订单',
'report' => '报告',
'supplement' => '补资料',
'ticket' => '工单',
default => '全部',
};
}
}

Some files were not shown because too many files have changed in this diff Show More