first
24
.gitignore
vendored
Normal 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
@@ -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)
|
||||
3
admin-web/.env.development
Normal 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
@@ -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.production
Normal 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
@@ -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
@@ -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
@@ -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
@@ -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
30
admin-web/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
1
admin-web/public/favicon.svg
Normal file
|
After Width: | Height: | Size: 9.3 KiB |
24
admin-web/public/icons.svg
Normal 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
@@ -0,0 +1,3 @@
|
||||
<template>
|
||||
<router-view />
|
||||
</template>
|
||||
2072
admin-web/src/api/admin.ts
Normal file
63
admin-web/src/api/request.ts
Normal 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;
|
||||
BIN
admin-web/src/assets/hero.png
Normal file
|
After Width: | Height: | Size: 44 KiB |
1
admin-web/src/assets/vite.svg
Normal file
|
After Width: | Height: | Size: 8.5 KiB |
1
admin-web/src/assets/vue.svg
Normal 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 |
93
admin-web/src/components/HelloWorld.vue
Normal 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>
|
||||
44
admin-web/src/components/OrderStatusTag.vue
Normal 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>
|
||||
93
admin-web/src/layouts/AdminLayout.vue
Normal 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
@@ -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");
|
||||
274
admin-web/src/pages/access/index.vue
Normal 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>
|
||||
2355
admin-web/src/pages/appraisal-tasks/index.vue
Normal file
1103
admin-web/src/pages/catalog/index.vue
Normal file
190
admin-web/src/pages/content/articles.vue
Normal 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>
|
||||
413
admin-web/src/pages/content/home.vue
Normal 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>
|
||||
83
admin-web/src/pages/content/index.vue
Normal 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>
|
||||
216
admin-web/src/pages/content/meta.vue
Normal 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>
|
||||
172
admin-web/src/pages/content/policy.vue
Normal 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>
|
||||
157
admin-web/src/pages/content/shared.ts
Normal 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);
|
||||
}
|
||||
512
admin-web/src/pages/customers/index.vue
Normal 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>
|
||||
31
admin-web/src/pages/dashboard/index.vue
Normal 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>
|
||||
57
admin-web/src/pages/login/index.vue
Normal 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>
|
||||
354
admin-web/src/pages/materials/index.vue
Normal 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>
|
||||
196
admin-web/src/pages/messages/index.vue
Normal 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>
|
||||
814
admin-web/src/pages/orders/index.vue
Normal 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>
|
||||
847
admin-web/src/pages/reports/index.vue
Normal 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>
|
||||
294
admin-web/src/pages/system-config/index.vue
Normal 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>
|
||||
316
admin-web/src/pages/tickets/index.vue
Normal 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>
|
||||
232
admin-web/src/pages/users/index.vue
Normal 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>
|
||||
306
admin-web/src/pages/warehouses/index.vue
Normal 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>
|
||||
258
admin-web/src/router/index.ts
Normal 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
@@ -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;
|
||||
}
|
||||
54
admin-web/src/utils/auth.ts
Normal 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);
|
||||
}
|
||||
24
admin-web/src/utils/env.ts
Normal 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;
|
||||
}
|
||||
33
admin-web/src/utils/navigation.ts
Normal 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"));
|
||||
}
|
||||
}
|
||||
14
admin-web/tsconfig.app.json
Normal 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
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
24
admin-web/tsconfig.node.json
Normal 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
@@ -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'
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
1401
design-prototypes/anxinyan-user-ui-overview-v1.html
Normal file
BIN
design-prototypes/anxinyan-user-ui-overview-v1.png
Normal file
|
After Width: | Height: | Size: 1.4 MiB |
1000
design-prototypes/anxinyan-user-ui-style-variants-v2-v3.html
Normal file
BIN
design-prototypes/anxinyan-user-ui-style-variants-v2-v3.png
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
5
docs/api/api-list.md
Normal file
@@ -0,0 +1,5 @@
|
||||
# API List
|
||||
|
||||
## 第三方开放接口
|
||||
|
||||
- [第三方订单对接文档](./third-party-openapi.md):客户推送订单、订单查询、签名鉴权、Webhook 回调说明。
|
||||
385
docs/api/third-party-openapi.md
Normal 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,平台在订单状态变化时主动通知第三方。
|
||||
3
docs/database/er-design.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# ER Design
|
||||
|
||||
Pending fill-in based on the confirmed table structure.
|
||||
17
docs/deploy/anxinyan-api.service.example
Normal 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
|
||||
21
docs/deploy/api.anxinjianyan.com.nginx.conf.example
Normal 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 代理逻辑。
|
||||
157
docs/deploy/backend-api-online-deploy.md
Normal 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`
|
||||
138
docs/deploy/delivery-notes.md
Normal 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
@@ -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. 当前状态
|
||||
|
||||
当前代码库已经具备:
|
||||
|
||||
- 用户端主流程
|
||||
- 后台订单履约主流程
|
||||
- 多仓库 / 改派仓库
|
||||
- 补料任务
|
||||
- 报告发布与验真
|
||||
- 寄回地址确认
|
||||
- 回寄运单登记
|
||||
- 用户签收闭环
|
||||
|
||||
剩余工作更偏向:
|
||||
|
||||
- 正式环境配置
|
||||
- 测试数据清理
|
||||
- 人工验收
|
||||
- 上线前口径确认
|
||||
113
docs/deploy/fulfillment-smoke-checklist.md
Normal 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 与小程序环境下都能正常扫描
|
||||
67
docs/deploy/release-checklist.md
Normal 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
@@ -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
@@ -0,0 +1,3 @@
|
||||
# MVP PRD
|
||||
|
||||
Pending fill-in based on the confirmed product scope.
|
||||
226
docs/static-data-audit-20260423.md
Normal 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. 最后再做字典化,把工单类型、辅助选项、结果模板迁到接口配置。
|
||||
17
server-api/.env.example
Normal 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
@@ -0,0 +1,8 @@
|
||||
/runtime
|
||||
/.idea
|
||||
/.vscode
|
||||
/vendor
|
||||
*.log
|
||||
.env
|
||||
/tests/tmp
|
||||
/tests/.phpunit.result.cache
|
||||
18
server-api/Dockerfile
Normal 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
@@ -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
@@ -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>
|
||||
|
||||
|
||||
22
server-api/app/bootstrap/Dotenv.php
Normal 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;
|
||||
}
|
||||
}
|
||||
42
server-api/app/controller/IndexController.php
Normal 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']);
|
||||
}
|
||||
|
||||
}
|
||||
296
server-api/app/controller/admin/AccessController.php
Normal 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();
|
||||
}
|
||||
}
|
||||
1614
server-api/app/controller/admin/AppraisalTasksController.php
Normal file
44
server-api/app/controller/admin/AuthController.php
Normal 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([], '已退出登录');
|
||||
}
|
||||
}
|
||||
889
server-api/app/controller/admin/CatalogController.php
Normal 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();
|
||||
}
|
||||
}
|
||||
155
server-api/app/controller/admin/ContentsController.php
Normal 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();
|
||||
}
|
||||
}
|
||||
393
server-api/app/controller/admin/CustomersController.php
Normal 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();
|
||||
}
|
||||
}
|
||||
66
server-api/app/controller/admin/DashboardController.php
Normal 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' => '已登记回寄运单,等待用户签收',
|
||||
],
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
80
server-api/app/controller/admin/MaterialsController.php
Normal 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();
|
||||
}
|
||||
}
|
||||
165
server-api/app/controller/admin/MessagesController.php
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
952
server-api/app/controller/admin/OrdersController.php
Normal 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;
|
||||
}
|
||||
}
|
||||
705
server-api/app/controller/admin/ReportsController.php
Normal 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' ? '中检合作机构' : '安心验';
|
||||
}
|
||||
}
|
||||
480
server-api/app/controller/admin/SystemConfigsController.php
Normal 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/uploads;OSS 模式写入阿里云对象存储。',
|
||||
'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('当前已切换为七牛云存储,请至少填写公开访问域名或七牛公网访问域名');
|
||||
}
|
||||
}
|
||||
}
|
||||
344
server-api/app/controller/admin/TicketsController.php
Normal 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();
|
||||
}
|
||||
}
|
||||
244
server-api/app/controller/admin/UsersController.php
Normal 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");
|
||||
}
|
||||
}
|
||||
69
server-api/app/controller/admin/WarehousesController.php
Normal 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();
|
||||
}
|
||||
}
|
||||
219
server-api/app/controller/app/AddressesController.php
Normal 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'] ?? '',
|
||||
];
|
||||
}
|
||||
}
|
||||
663
server-api/app/controller/app/AppraisalController.php
Normal 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';
|
||||
}
|
||||
}
|
||||
112
server-api/app/controller/app/AuthController.php
Normal 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([], '已退出登录');
|
||||
}
|
||||
}
|
||||
42
server-api/app/controller/app/CatalogController.php
Normal 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(),
|
||||
]);
|
||||
}
|
||||
|
||||
}
|
||||
101
server-api/app/controller/app/HelpCenterController.php
Normal 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);
|
||||
}
|
||||
}
|
||||
94
server-api/app/controller/app/HomeController.php
Normal 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);
|
||||
}
|
||||
}
|
||||
49
server-api/app/controller/app/MaterialTagsController.php
Normal 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();
|
||||
}
|
||||
}
|
||||
277
server-api/app/controller/app/MessagesController.php
Normal 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 => '全部',
|
||||
};
|
||||
}
|
||||
}
|
||||