feat: finalize delivery build and replacement checklist
This commit is contained in:
parent
4f35ef9dd0
commit
f1e44178f8
119
docs/最终替换清单.md
Normal file
119
docs/最终替换清单.md
Normal file
@ -0,0 +1,119 @@
|
||||
# 最终替换清单
|
||||
|
||||
本文档只列最终验收或上线前需要你手动替换的内容,尽量做到不改业务代码,只改配置。
|
||||
|
||||
## 后端
|
||||
|
||||
文件:
|
||||
|
||||
- [backend/.env.example](D:/work/python/coding/dingdanquanliucheng/backend/.env.example)
|
||||
|
||||
你需要复制为:
|
||||
|
||||
- `backend/.env`
|
||||
|
||||
重点替换项:
|
||||
|
||||
- `SECRET_KEY`
|
||||
说明:JWT 签名密钥,必须换成正式随机字符串。
|
||||
- `MYSQL_HOST`
|
||||
- `MYSQL_PORT`
|
||||
- `MYSQL_DATABASE`
|
||||
- `MYSQL_USER`
|
||||
- `MYSQL_PASSWORD`
|
||||
说明:以上五项替换成正式 MySQL 连接信息。
|
||||
- `ALIYUN_OSS_BUCKET`
|
||||
- `ALIYUN_OSS_ENDPOINT`
|
||||
- `ALIYUN_OSS_ACCESS_KEY_ID`
|
||||
- `ALIYUN_OSS_ACCESS_KEY_SECRET`
|
||||
- `ALIYUN_OSS_PUBLIC_BASE_URL`
|
||||
说明:以上五项替换成正式 OSS 配置。
|
||||
- `AI_PROVIDER`
|
||||
说明:如果要启用真实 OCR,改成 `aliyun`。
|
||||
- `ALIYUN_OCR_ENDPOINT`
|
||||
- `ALIYUN_OCR_PATH`
|
||||
- `ALIYUN_OCR_APPCODE`
|
||||
说明:替换成正式 OCR 接口配置。
|
||||
- `LOGISTICS_TRACE_PROVIDER`
|
||||
- `LOGISTICS_TRACE_ENDPOINT`
|
||||
- `LOGISTICS_TRACE_PATH`
|
||||
- `LOGISTICS_TRACE_APPCODE`
|
||||
说明:替换成正式第三方物流轨迹服务配置。
|
||||
- `CORS_ALLOW_ORIGINS`
|
||||
说明:替换成实际前端访问域名,多个地址用英文逗号分隔。
|
||||
|
||||
## Web 管理后台
|
||||
|
||||
文件:
|
||||
|
||||
- [frontend/web-admin/.env.example](D:/work/python/coding/dingdanquanliucheng/frontend/web-admin/.env.example)
|
||||
|
||||
你需要新建:
|
||||
|
||||
- `frontend/web-admin/.env.production`
|
||||
或本地联调用 `frontend/web-admin/.env.local`
|
||||
|
||||
需要替换:
|
||||
|
||||
- `VITE_API_BASE_URL`
|
||||
说明:后端真实地址。
|
||||
- `VITE_ENABLE_DEMO_FALLBACK`
|
||||
说明:最终验收建议保持 `false`,避免接口失败时自动回退演示数据。
|
||||
|
||||
## Web 业务员端
|
||||
|
||||
文件:
|
||||
|
||||
- [frontend/web-sales/.env.example](D:/work/python/coding/dingdanquanliucheng/frontend/web-sales/.env.example)
|
||||
|
||||
你需要新建:
|
||||
|
||||
- `frontend/web-sales/.env.production`
|
||||
或本地联调用 `frontend/web-sales/.env.local`
|
||||
|
||||
需要替换:
|
||||
|
||||
- `VITE_API_BASE_URL`
|
||||
说明:后端真实地址。
|
||||
- `VITE_ENABLE_DEMO_FALLBACK`
|
||||
说明:最终验收建议保持 `false`。
|
||||
|
||||
## 微信管理层小程序
|
||||
|
||||
文件:
|
||||
|
||||
- [frontend/mini-manager/config.js](D:/work/python/coding/dingdanquanliucheng/frontend/mini-manager/config.js)
|
||||
|
||||
需要替换:
|
||||
|
||||
- `apiBaseUrl`
|
||||
说明:替换成后端真实 HTTPS 地址,并确保该地址已加入微信小程序合法域名。
|
||||
|
||||
## 微信司机小程序
|
||||
|
||||
文件:
|
||||
|
||||
- [frontend/mini-driver/config.js](D:/work/python/coding/dingdanquanliucheng/frontend/mini-driver/config.js)
|
||||
|
||||
需要替换:
|
||||
|
||||
- `apiBaseUrl`
|
||||
说明:替换成后端真实 HTTPS 地址,并确保该地址已加入微信小程序合法域名。
|
||||
|
||||
## 上线前还要确认
|
||||
|
||||
- 已执行 `alembic upgrade head`
|
||||
- 已执行 `python backend/scripts/bootstrap_data.py`
|
||||
- 已修改管理员初始密码
|
||||
- OSS Bucket 权限与公网访问策略已确认
|
||||
- OCR 与物流服务商测试凭证已切换正式凭证
|
||||
- 小程序合法域名已配置
|
||||
|
||||
## 建议验收顺序
|
||||
|
||||
1. 后端启动并检查 `/health`
|
||||
2. 执行 `python backend/scripts/smoke_check.py`
|
||||
3. 构建 `web-admin` 与 `web-sales`
|
||||
4. 管理后台验收:审批、履约、报表、系统管理
|
||||
5. 业务员端验收:登录、建单、提交、取消、提醒
|
||||
6. 小程序验收:管理层审批、司机接单/揽货/送达
|
||||
@ -18,16 +18,40 @@ python scripts/smoke_check.py
|
||||
- OSS 上传签名地址是否能正常生成
|
||||
- token 持久化失效目录是否可写
|
||||
|
||||
## 2. 测试环境部署步骤
|
||||
## 2. 当前可直接执行的前端构建
|
||||
|
||||
Web 管理后台:
|
||||
|
||||
```bash
|
||||
cd frontend/web-admin
|
||||
npm install
|
||||
npm run build
|
||||
```
|
||||
|
||||
Web 业务员端:
|
||||
|
||||
```bash
|
||||
cd frontend/web-sales
|
||||
npm install
|
||||
npm run build
|
||||
```
|
||||
|
||||
说明:
|
||||
|
||||
- 两个 Web 前端当前都已完成生产构建验证,可直接输出 `dist` 产物。
|
||||
- 最终上线前请先按 [最终替换清单](D:/work/python/coding/dingdanquanliucheng/docs/最终替换清单.md) 替换配置。
|
||||
|
||||
## 3. 测试环境部署步骤
|
||||
|
||||
1. 准备 MySQL 8.0+ 和 `.env`
|
||||
2. 执行 `alembic upgrade head`
|
||||
3. 执行 `python scripts/bootstrap_data.py`
|
||||
4. 启动后端:`uvicorn main:app --reload`
|
||||
5. 执行 `python scripts/smoke_check.py`
|
||||
6. 按 `开发细节/04-测试用例清单.md` 依次跑认证、订单、履约、提醒、报表、系统管理用例
|
||||
6. 构建 `frontend/web-admin` 与 `frontend/web-sales`
|
||||
7. 按 `开发细节/04-测试用例清单.md` 依次跑认证、订单、履约、提醒、报表、系统管理用例
|
||||
|
||||
## 3. 当前已具备的关键验收点
|
||||
## 4. 当前已具备的关键验收点
|
||||
|
||||
- 认证:登录、`/me`、`/logout`、token 持久化失效
|
||||
- 订单:建单、提交、审批、取消、发厂确认、状态流转
|
||||
@ -35,8 +59,10 @@ python scripts/smoke_check.py
|
||||
- 文件:OSS 预签名上传地址、附件落库、文件校验
|
||||
- 提醒:欠款、沉默客户、物流超时、统一触发入口
|
||||
- 报表:月/季/年统计、CSV/XLSX 双导出、附件与审计留痕
|
||||
- 前端:`web-admin` 已补审批、履约、系统管理、报表页,`web-sales` 已补订单和真实提醒页,两端已通过构建
|
||||
- 配置:最终替换项已单独整理到 [最终替换清单](D:/work/python/coding/dingdanquanliucheng/docs/最终替换清单.md)
|
||||
|
||||
## 4. 剩余外部联调项
|
||||
## 5. 剩余外部联调项
|
||||
|
||||
- 第三方物流轨迹正式服务商地址与凭证
|
||||
- 阿里云 OCR 正式生产配置
|
||||
|
||||
@ -1,7 +1,9 @@
|
||||
const { apiBaseUrl } = require("./config");
|
||||
|
||||
App({
|
||||
globalData: {
|
||||
appName: "mini-driver",
|
||||
apiBaseUrl: "http://127.0.0.1:8000",
|
||||
apiBaseUrl,
|
||||
authToken: "",
|
||||
driverUser: null,
|
||||
},
|
||||
|
||||
5
frontend/mini-driver/config.js
Normal file
5
frontend/mini-driver/config.js
Normal file
@ -0,0 +1,5 @@
|
||||
const apiBaseUrl = "http://127.0.0.1:8000";
|
||||
|
||||
module.exports = {
|
||||
apiBaseUrl,
|
||||
};
|
||||
@ -1,7 +1,9 @@
|
||||
const { apiBaseUrl } = require("./config");
|
||||
|
||||
App({
|
||||
globalData: {
|
||||
appName: "mini-manager",
|
||||
apiBaseUrl: "http://127.0.0.1:8000",
|
||||
apiBaseUrl,
|
||||
authToken: "",
|
||||
managerUser: null,
|
||||
},
|
||||
|
||||
5
frontend/mini-manager/config.js
Normal file
5
frontend/mini-manager/config.js
Normal file
@ -0,0 +1,5 @@
|
||||
const apiBaseUrl = "http://127.0.0.1:8000";
|
||||
|
||||
module.exports = {
|
||||
apiBaseUrl,
|
||||
};
|
||||
2
frontend/web-admin/.env.example
Normal file
2
frontend/web-admin/.env.example
Normal file
@ -0,0 +1,2 @@
|
||||
VITE_API_BASE_URL=http://127.0.0.1:8000
|
||||
VITE_ENABLE_DEMO_FALLBACK=false
|
||||
1346
frontend/web-admin/package-lock.json
generated
Normal file
1346
frontend/web-admin/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@ -12,6 +12,7 @@
|
||||
"vue-router": "^4.5.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.2.4",
|
||||
"vite": "^6.3.5"
|
||||
}
|
||||
}
|
||||
|
||||
@ -8,6 +8,7 @@
|
||||
<RouterLink to="/system">系统管理</RouterLink>
|
||||
<RouterLink to="/master-data">基础资料</RouterLink>
|
||||
<RouterLink to="/business">业务模块</RouterLink>
|
||||
<RouterLink to="/reports/performance">报表统计</RouterLink>
|
||||
<button class="logout-btn" @click="handleLogout">退出</button>
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
5
frontend/web-admin/src/config.js
Normal file
5
frontend/web-admin/src/config.js
Normal file
@ -0,0 +1,5 @@
|
||||
const envApiBaseUrl = (import.meta.env.VITE_API_BASE_URL || "").trim();
|
||||
|
||||
// 前端统一从环境变量读取接口地址,最终验收和上线时只需要替换 .env 配置。
|
||||
export const apiBaseUrl = envApiBaseUrl || "http://127.0.0.1:8000";
|
||||
export const enableDemoFallback = String(import.meta.env.VITE_ENABLE_DEMO_FALLBACK || "false").toLowerCase() === "true";
|
||||
@ -1,4 +1,4 @@
|
||||
import {
|
||||
import {
|
||||
businessModules,
|
||||
customerRows,
|
||||
dashboardCards,
|
||||
@ -8,8 +8,9 @@ import {
|
||||
systemModules,
|
||||
taskRows
|
||||
} from "./data";
|
||||
import { apiBaseUrl, enableDemoFallback } from "./config";
|
||||
|
||||
const API_BASE_URL = "http://127.0.0.1:8000";
|
||||
const API_BASE_URL = apiBaseUrl;
|
||||
const ADMIN_TOKEN_KEY = "admin_token";
|
||||
|
||||
function wait(data) {
|
||||
@ -46,6 +47,10 @@ async function request(path, options = {}) {
|
||||
return result.data;
|
||||
}
|
||||
|
||||
function shouldUseDemoFallback(error) {
|
||||
return enableDemoFallback && Boolean(error);
|
||||
}
|
||||
|
||||
export async function loginAdminUser(form) {
|
||||
const data = await request("/api/auth/login", {
|
||||
method: "POST",
|
||||
@ -155,6 +160,9 @@ export async function fetchSystemUsers(filters = {}) {
|
||||
isMock: false,
|
||||
};
|
||||
} catch (error) {
|
||||
if (!shouldUseDemoFallback(error)) {
|
||||
throw error;
|
||||
}
|
||||
return wait({
|
||||
rows: [
|
||||
{
|
||||
@ -218,6 +226,9 @@ export async function fetchSystemRoles(filters = {}) {
|
||||
isMock: false,
|
||||
};
|
||||
} catch (error) {
|
||||
if (!shouldUseDemoFallback(error)) {
|
||||
throw error;
|
||||
}
|
||||
return wait({
|
||||
rows: [
|
||||
{
|
||||
@ -297,6 +308,9 @@ export async function fetchSystemMenus(filters = {}) {
|
||||
isMock: false,
|
||||
};
|
||||
} catch (error) {
|
||||
if (!shouldUseDemoFallback(error)) {
|
||||
throw error;
|
||||
}
|
||||
return wait({
|
||||
rows: [
|
||||
{
|
||||
@ -386,6 +400,9 @@ export async function fetchCustomerList(filters = {}) {
|
||||
isMock: false,
|
||||
};
|
||||
} catch (error) {
|
||||
if (!shouldUseDemoFallback(error)) {
|
||||
throw error;
|
||||
}
|
||||
return wait({
|
||||
rows: customerRows.map((item, index) => ({
|
||||
customerId: 3000 + index + 1,
|
||||
@ -448,6 +465,9 @@ export async function fetchProductCategoryList(filters = {}) {
|
||||
isMock: false,
|
||||
};
|
||||
} catch (error) {
|
||||
if (!shouldUseDemoFallback(error)) {
|
||||
throw error;
|
||||
}
|
||||
return wait({
|
||||
rows: [
|
||||
{
|
||||
@ -505,6 +525,9 @@ export async function fetchProductList(filters = {}) {
|
||||
isMock: false,
|
||||
};
|
||||
} catch (error) {
|
||||
if (!shouldUseDemoFallback(error)) {
|
||||
throw error;
|
||||
}
|
||||
return wait({
|
||||
rows: [
|
||||
{
|
||||
@ -561,6 +584,9 @@ export async function fetchSupplierList(filters = {}) {
|
||||
isMock: false,
|
||||
};
|
||||
} catch (error) {
|
||||
if (!shouldUseDemoFallback(error)) {
|
||||
throw error;
|
||||
}
|
||||
return wait({
|
||||
rows: [
|
||||
{
|
||||
@ -584,6 +610,9 @@ export async function fetchSupplierDetail(supplierId) {
|
||||
try {
|
||||
return await request(`/api/suppliers/${supplierId}`);
|
||||
} catch (error) {
|
||||
if (!shouldUseDemoFallback(error)) {
|
||||
throw error;
|
||||
}
|
||||
return wait({
|
||||
supplier_id: supplierId,
|
||||
supplier_name: "工厂A",
|
||||
@ -631,6 +660,9 @@ export async function fetchFactoryPendingOrders() {
|
||||
isMock: false,
|
||||
};
|
||||
} catch (error) {
|
||||
if (!shouldUseDemoFallback(error)) {
|
||||
throw error;
|
||||
}
|
||||
return wait({
|
||||
rows: [
|
||||
{
|
||||
@ -674,6 +706,9 @@ export async function fetchLogisticsTasks(filters = {}) {
|
||||
const data = await request(`/api/logistics/tasks?${query}`);
|
||||
return { rows: data.list || [], isMock: false };
|
||||
} catch (error) {
|
||||
if (!shouldUseDemoFallback(error)) {
|
||||
throw error;
|
||||
}
|
||||
return wait({
|
||||
rows: taskRows.map((item, index) => ({
|
||||
task_id: index + 1,
|
||||
@ -734,6 +769,9 @@ export async function fetchApprovalDashboard() {
|
||||
isMock: false,
|
||||
};
|
||||
} catch (error) {
|
||||
if (!shouldUseDemoFallback(error)) {
|
||||
throw error;
|
||||
}
|
||||
return wait({
|
||||
summaryCards: [
|
||||
{ label: "待订单审批", value: 2 },
|
||||
@ -774,6 +812,9 @@ export async function fetchApprovalList(filters = {}) {
|
||||
rows.sort((first, second) => String(second.createdAt).localeCompare(String(first.createdAt)));
|
||||
return { rows, isMock: false };
|
||||
} catch (error) {
|
||||
if (!shouldUseDemoFallback(error)) {
|
||||
throw error;
|
||||
}
|
||||
return wait({
|
||||
rows: recentOrders.map((item, index) => ({
|
||||
orderId: index + 1,
|
||||
@ -832,6 +873,9 @@ export async function fetchApprovalDetail(orderId) {
|
||||
isMock: false,
|
||||
};
|
||||
} catch (error) {
|
||||
if (!shouldUseDemoFallback(error)) {
|
||||
throw error;
|
||||
}
|
||||
return wait({
|
||||
orderInfo: {
|
||||
orderId,
|
||||
@ -880,3 +924,48 @@ export async function cancelApproveOrder(orderId, payload) {
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchPerformanceReport(filters = {}) {
|
||||
try {
|
||||
const query = buildQuery({
|
||||
stat_type: filters.stat_type || "month",
|
||||
start_date: filters.start_date || undefined,
|
||||
end_date: filters.end_date || undefined,
|
||||
category_id: filters.category_id || undefined,
|
||||
});
|
||||
const data = await request(`/api/reports/performance?${query}`);
|
||||
return { ...data, isMock: false };
|
||||
} catch (error) {
|
||||
if (!shouldUseDemoFallback(error)) {
|
||||
throw error;
|
||||
}
|
||||
return wait({
|
||||
stat_type: filters.stat_type || "month",
|
||||
list: [
|
||||
{
|
||||
stat_period: "2026-05",
|
||||
order_count: 3,
|
||||
order_amount: 1640,
|
||||
commission_amount: 150,
|
||||
category_amounts: [
|
||||
{ category_id: 1, category_name: "工业品", amount: 980 },
|
||||
{ category_id: 2, category_name: "日用品", amount: 660 },
|
||||
],
|
||||
},
|
||||
],
|
||||
isMock: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function exportPerformanceReport(filters = {}) {
|
||||
return request(
|
||||
`/api/reports/performance/export?${buildQuery({
|
||||
stat_type: filters.stat_type || "month",
|
||||
start_date: filters.start_date || undefined,
|
||||
end_date: filters.end_date || undefined,
|
||||
category_id: filters.category_id || undefined,
|
||||
export_format: filters.export_format || "csv",
|
||||
})}`
|
||||
);
|
||||
}
|
||||
|
||||
@ -7,6 +7,7 @@ import MasterDataPage from "./views/MasterDataPage.vue";
|
||||
import BusinessPage from "./views/BusinessPage.vue";
|
||||
import ApprovalsPage from "./views/ApprovalsPage.vue";
|
||||
import ApprovalDetailPage from "./views/ApprovalDetailPage.vue";
|
||||
import ReportsPage from "./views/ReportsPage.vue";
|
||||
|
||||
const routes = [
|
||||
{ path: "/", redirect: "/login" },
|
||||
@ -16,7 +17,8 @@ const routes = [
|
||||
{ path: "/approvals/:id", component: ApprovalDetailPage },
|
||||
{ path: "/system", component: SystemPage },
|
||||
{ path: "/master-data", component: MasterDataPage },
|
||||
{ path: "/business", component: BusinessPage }
|
||||
{ path: "/business", component: BusinessPage },
|
||||
{ path: "/reports/performance", component: ReportsPage }
|
||||
];
|
||||
|
||||
export const router = createRouter({
|
||||
|
||||
242
frontend/web-admin/src/views/ReportsPage.vue
Normal file
242
frontend/web-admin/src/views/ReportsPage.vue
Normal file
@ -0,0 +1,242 @@
|
||||
<template>
|
||||
<section class="page">
|
||||
<div class="header-row">
|
||||
<div>
|
||||
<h2>报表统计</h2>
|
||||
<p class="subline">对齐 `/api/reports/performance` 与 `/api/reports/performance/export`,支持查询和导出。</p>
|
||||
</div>
|
||||
<span class="mode-tag" :class="{ fallback: isMock }">
|
||||
{{ isMock ? "演示回退" : "真实接口" }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-if="message" class="message-box" :class="{ error: messageType === 'error' }">
|
||||
{{ message }}
|
||||
</div>
|
||||
|
||||
<section class="toolbar toolbar-4">
|
||||
<label>
|
||||
<span>统计维度</span>
|
||||
<select v-model="filters.stat_type">
|
||||
<option value="month">按月</option>
|
||||
<option value="quarter">按季度</option>
|
||||
<option value="year">按年度</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>开始日期</span>
|
||||
<input v-model="filters.start_date" type="date" />
|
||||
</label>
|
||||
<label>
|
||||
<span>结束日期</span>
|
||||
<input v-model="filters.end_date" type="date" />
|
||||
</label>
|
||||
<label>
|
||||
<span>产品分类</span>
|
||||
<select v-model="filters.category_id">
|
||||
<option value="">全部分类</option>
|
||||
<option v-for="item in categoryOptions" :key="item.value" :value="String(item.value)">
|
||||
{{ item.label }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
<div class="toolbar-actions">
|
||||
<button :disabled="loading" @click="handleSearch">
|
||||
{{ loading ? "查询中..." : "查询报表" }}
|
||||
</button>
|
||||
<button :disabled="loading" @click="handleReset">重置</button>
|
||||
<button :disabled="exporting === 'csv'" @click="handleExport('csv')">
|
||||
{{ exporting === "csv" ? "导出中..." : "导出 CSV" }}
|
||||
</button>
|
||||
<button :disabled="exporting === 'xlsx'" @click="handleExport('xlsx')">
|
||||
{{ exporting === "xlsx" ? "导出中..." : "导出 XLSX" }}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<p v-if="loading" class="loading">正在加载报表统计...</p>
|
||||
<div v-else-if="!rows.length" class="empty-state">当前条件下没有统计数据</div>
|
||||
<section v-else class="table-panel">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>统计周期</th>
|
||||
<th>订单数</th>
|
||||
<th>订单金额</th>
|
||||
<th>固定提成</th>
|
||||
<th>分类金额明细</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="row in rows" :key="row.stat_period">
|
||||
<td>{{ row.stat_period }}</td>
|
||||
<td>{{ row.order_count }}</td>
|
||||
<td>{{ formatAmount(row.order_amount) }}</td>
|
||||
<td>{{ formatAmount(row.commission_amount) }}</td>
|
||||
<td>
|
||||
<div class="category-list">
|
||||
<span v-for="item in row.category_amounts" :key="`${row.stat_period}-${item.category_id}`" class="category-chip">
|
||||
{{ item.category_name }}:{{ formatAmount(item.amount) }}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<section v-if="lastExport" class="detail-panel">
|
||||
<div class="panel-header compact">
|
||||
<h3>最近一次导出结果</h3>
|
||||
<span>{{ lastExport.file_name }}</span>
|
||||
</div>
|
||||
<div class="detail-grid">
|
||||
<article>
|
||||
<h4>文件信息</h4>
|
||||
<p>文件名:{{ lastExport.file_name }}</p>
|
||||
<p>对象 Key:{{ lastExport.object_key }}</p>
|
||||
<p>存储桶:{{ lastExport.bucket_name }}</p>
|
||||
</article>
|
||||
<article>
|
||||
<h4>导出口径</h4>
|
||||
<p>统计维度:{{ lastExport.stat_type }}</p>
|
||||
<p>导出格式:{{ lastExport.export_format }}</p>
|
||||
<p>统计周期数:{{ lastExport.total_periods }}</p>
|
||||
</article>
|
||||
<article class="full-width">
|
||||
<h4>访问地址</h4>
|
||||
<p class="break-all">{{ lastExport.file_url }}</p>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, reactive, ref } from "vue";
|
||||
|
||||
import { fetchProductCategoryList, exportPerformanceReport, fetchPerformanceReport } from "../mockApi";
|
||||
|
||||
const loading = ref(true);
|
||||
const exporting = ref("");
|
||||
const isMock = ref(false);
|
||||
const rows = ref([]);
|
||||
const categoryOptions = ref([]);
|
||||
const lastExport = ref(null);
|
||||
const message = ref("");
|
||||
const messageType = ref("success");
|
||||
const filters = reactive({
|
||||
stat_type: "month",
|
||||
start_date: "",
|
||||
end_date: "",
|
||||
category_id: "",
|
||||
});
|
||||
|
||||
function setMessage(text, type = "success") {
|
||||
message.value = text;
|
||||
messageType.value = type;
|
||||
}
|
||||
|
||||
function formatAmount(value) {
|
||||
return Number(value || 0).toFixed(2);
|
||||
}
|
||||
|
||||
async function loadCategoryOptions() {
|
||||
const categoryData = await fetchProductCategoryList({});
|
||||
categoryOptions.value = (categoryData.rows || []).map((item) => ({
|
||||
value: item.categoryId,
|
||||
label: `${item.categoryName} / ${item.categoryCode}`,
|
||||
}));
|
||||
}
|
||||
|
||||
function buildFilters(extra = {}) {
|
||||
return {
|
||||
stat_type: filters.stat_type,
|
||||
start_date: filters.start_date || undefined,
|
||||
end_date: filters.end_date || undefined,
|
||||
category_id: filters.category_id ? Number(filters.category_id) : undefined,
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
async function loadReports() {
|
||||
const data = await fetchPerformanceReport(buildFilters());
|
||||
rows.value = data.list || [];
|
||||
isMock.value = Boolean(data.isMock);
|
||||
}
|
||||
|
||||
async function handleSearch() {
|
||||
loading.value = true;
|
||||
setMessage("");
|
||||
try {
|
||||
await loadReports();
|
||||
} catch (error) {
|
||||
setMessage(error.message || "加载报表失败", "error");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleReset() {
|
||||
filters.stat_type = "month";
|
||||
filters.start_date = "";
|
||||
filters.end_date = "";
|
||||
filters.category_id = "";
|
||||
await handleSearch();
|
||||
}
|
||||
|
||||
async function handleExport(exportFormat) {
|
||||
exporting.value = exportFormat;
|
||||
setMessage("");
|
||||
try {
|
||||
const data = await exportPerformanceReport(buildFilters({ export_format: exportFormat }));
|
||||
lastExport.value = data;
|
||||
setMessage(`报表已导出:${data.file_name}`);
|
||||
} catch (error) {
|
||||
setMessage(error.message || "导出报表失败", "error");
|
||||
} finally {
|
||||
exporting.value = "";
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
await loadCategoryOptions();
|
||||
await loadReports();
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page { background: #fff; border-radius: 8px; padding: 20px; }
|
||||
.header-row { display: flex; justify-content: space-between; align-items: center; gap: 12px; margin-bottom: 16px; }
|
||||
h2 { margin: 0 0 6px; }
|
||||
.subline { margin: 0; color: #6b7280; font-size: 13px; }
|
||||
.mode-tag { padding: 6px 10px; border-radius: 999px; background: #dcfce7; color: #166534; font-size: 12px; }
|
||||
.mode-tag.fallback { background: #fef3c7; color: #92400e; }
|
||||
.toolbar { display: grid; gap: 12px; margin-bottom: 16px; align-items: end; }
|
||||
.toolbar-4 { grid-template-columns: repeat(4, minmax(0, 1fr)) auto; }
|
||||
label { display: grid; gap: 6px; }
|
||||
label span { color: #4b5563; font-size: 13px; }
|
||||
input, select, button { border: 1px solid #d1d5db; border-radius: 8px; padding: 10px 12px; background: #fff; }
|
||||
.toolbar-actions { display: flex; gap: 10px; align-items: end; }
|
||||
.message-box { margin-bottom: 16px; padding: 12px 14px; border-radius: 8px; background: #dcfce7; color: #166534; }
|
||||
.message-box.error { background: #fee2e2; color: #b91c1c; }
|
||||
.loading { margin: 0; color: #6b7280; }
|
||||
.empty-state { padding: 24px; border: 1px dashed #d1d5db; border-radius: 8px; color: #6b7280; text-align: center; }
|
||||
.table-panel, .detail-panel { border: 1px solid #e5e7eb; border-radius: 8px; padding: 14px; margin-top: 20px; }
|
||||
table { width: 100%; border-collapse: collapse; }
|
||||
th, td { text-align: left; padding: 8px; border-bottom: 1px solid #e5e7eb; vertical-align: top; }
|
||||
.category-list { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||
.category-chip { display: inline-block; padding: 4px 10px; border-radius: 999px; background: #f3f4f6; color: #374151; }
|
||||
.panel-header { display: flex; justify-content: space-between; align-items: center; gap: 12px; margin-bottom: 12px; }
|
||||
.panel-header.compact h3 { margin: 0; }
|
||||
.detail-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; }
|
||||
.detail-grid article { border: 1px solid #e5e7eb; border-radius: 8px; padding: 12px; }
|
||||
.detail-grid h4 { margin: 0 0 8px; }
|
||||
.detail-grid p { margin: 0; line-height: 1.7; color: #4b5563; }
|
||||
.full-width { grid-column: 1 / -1; }
|
||||
.break-all { word-break: break-all; }
|
||||
</style>
|
||||
@ -1,3 +1,6 @@
|
||||
import { defineConfig } from "vite";
|
||||
import vue from "@vitejs/plugin-vue";
|
||||
|
||||
export default defineConfig({});
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
});
|
||||
|
||||
2
frontend/web-sales/.env.example
Normal file
2
frontend/web-sales/.env.example
Normal file
@ -0,0 +1,2 @@
|
||||
VITE_API_BASE_URL=http://127.0.0.1:8000
|
||||
VITE_ENABLE_DEMO_FALLBACK=false
|
||||
1346
frontend/web-sales/package-lock.json
generated
Normal file
1346
frontend/web-sales/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@ -12,6 +12,7 @@
|
||||
"vue-router": "^4.5.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.2.4",
|
||||
"vite": "^6.3.5"
|
||||
}
|
||||
}
|
||||
|
||||
5
frontend/web-sales/src/config.js
Normal file
5
frontend/web-sales/src/config.js
Normal file
@ -0,0 +1,5 @@
|
||||
const envApiBaseUrl = (import.meta.env.VITE_API_BASE_URL || "").trim();
|
||||
|
||||
// 业务员端也统一走环境变量,避免最终交付时还要改源码里的接口地址。
|
||||
export const apiBaseUrl = envApiBaseUrl || "http://127.0.0.1:8000";
|
||||
export const enableDemoFallback = String(import.meta.env.VITE_ENABLE_DEMO_FALLBACK || "false").toLowerCase() === "true";
|
||||
@ -6,9 +6,10 @@ import {
|
||||
reminderRows,
|
||||
reminderTypes,
|
||||
} from "./data";
|
||||
import { apiBaseUrl, enableDemoFallback } from "./config";
|
||||
import { salesStore } from "./store";
|
||||
|
||||
const API_BASE_URL = "http://127.0.0.1:8000";
|
||||
const API_BASE_URL = apiBaseUrl;
|
||||
|
||||
function wait(data) {
|
||||
return new Promise((resolve) => {
|
||||
@ -44,6 +45,10 @@ async function request(path, options = {}) {
|
||||
return result.data;
|
||||
}
|
||||
|
||||
function shouldUseDemoFallback(error) {
|
||||
return enableDemoFallback && Boolean(error);
|
||||
}
|
||||
|
||||
function mapOrderStatus(status) {
|
||||
const statusMap = {
|
||||
draft: "草稿",
|
||||
@ -86,6 +91,9 @@ export async function loginSalesUser(form) {
|
||||
isMock: false,
|
||||
};
|
||||
} catch (error) {
|
||||
if (!shouldUseDemoFallback(error)) {
|
||||
throw error;
|
||||
}
|
||||
return wait({
|
||||
user: {
|
||||
username: form.username || "sales01",
|
||||
@ -133,6 +141,9 @@ export async function fetchOrdersPage(filters = {}) {
|
||||
isMock: false,
|
||||
};
|
||||
} catch (error) {
|
||||
if (!shouldUseDemoFallback(error)) {
|
||||
throw error;
|
||||
}
|
||||
return wait({
|
||||
orderFilters,
|
||||
orderActions,
|
||||
@ -234,6 +245,9 @@ export async function fetchOrderDetail(orderId) {
|
||||
isMock: false,
|
||||
};
|
||||
} catch (error) {
|
||||
if (!shouldUseDemoFallback(error)) {
|
||||
throw error;
|
||||
}
|
||||
return wait({
|
||||
blocks: [
|
||||
{ title: "基本信息", content: "订单编号、客户、状态、来源等信息将在接入真实接口后展示。" },
|
||||
@ -281,6 +295,24 @@ export async function cancelOrder(orderId, cancelReason = "业务员取消订单
|
||||
});
|
||||
}
|
||||
|
||||
export function fetchRemindersPage() {
|
||||
return wait({ reminderTypes, reminderRows });
|
||||
export async function fetchRemindersPage() {
|
||||
try {
|
||||
const data = await request("/api/reminders?page_no=1&page_size=50");
|
||||
return {
|
||||
reminderTypes: Array.from(new Set((data.list || []).map((item) => item.type || "-"))),
|
||||
reminderRows: (data.list || []).map((item) => ({
|
||||
reminderId: item.reminder_id,
|
||||
title: item.title,
|
||||
type: item.type,
|
||||
status: item.status,
|
||||
createdAt: item.created_at || item.sent_at || "-",
|
||||
})),
|
||||
isMock: false,
|
||||
};
|
||||
} catch (error) {
|
||||
if (!shouldUseDemoFallback(error)) {
|
||||
throw error;
|
||||
}
|
||||
return wait({ reminderTypes, reminderRows, isMock: true });
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,6 +1,11 @@
|
||||
<template>
|
||||
<section class="page">
|
||||
<h2>提醒中心</h2>
|
||||
<div class="header-row">
|
||||
<h2>提醒中心</h2>
|
||||
<span class="mode-tag" :class="{ fallback: isMock }">
|
||||
{{ isMock ? "演示回退" : "真实接口" }}
|
||||
</span>
|
||||
</div>
|
||||
<p v-if="loading" class="loading">加载中...</p>
|
||||
<div v-else class="chips">
|
||||
<button v-for="item in reminderTypes" :key="item" class="chip">{{ item }}</button>
|
||||
@ -34,6 +39,7 @@ import { onMounted, ref } from "vue";
|
||||
import { fetchRemindersPage } from "../mockApi";
|
||||
|
||||
const loading = ref(true);
|
||||
const isMock = ref(false);
|
||||
const reminderTypes = ref([]);
|
||||
const reminderRows = ref([]);
|
||||
|
||||
@ -41,14 +47,18 @@ onMounted(async () => {
|
||||
const data = await fetchRemindersPage();
|
||||
reminderTypes.value = data.reminderTypes;
|
||||
reminderRows.value = data.reminderRows;
|
||||
isMock.value = Boolean(data.isMock);
|
||||
loading.value = false;
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page { background: #fff; border-radius: 8px; padding: 20px; }
|
||||
h2 { margin: 0 0 12px; }
|
||||
.header-row { display: flex; justify-content: space-between; align-items: center; gap: 12px; margin-bottom: 12px; }
|
||||
h2 { margin: 0; }
|
||||
.loading { margin: 0; color: #6b7280; }
|
||||
.mode-tag { padding: 6px 10px; border-radius: 999px; background: #dcfce7; color: #166534; font-size: 12px; }
|
||||
.mode-tag.fallback { background: #fef3c7; color: #92400e; }
|
||||
.chips { display: flex; flex-wrap: wrap; gap: 10px; }
|
||||
.chip { padding: 8px 12px; border: 1px solid #d1d5db; border-radius: 999px; background: #f9fafb; }
|
||||
.table-panel { margin-top: 20px; border: 1px solid #e5e7eb; border-radius: 8px; padding: 14px; }
|
||||
|
||||
@ -1,3 +1,6 @@
|
||||
import { defineConfig } from "vite";
|
||||
import vue from "@vitejs/plugin-vue";
|
||||
|
||||
export default defineConfig({});
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
});
|
||||
|
||||
@ -82,12 +82,11 @@
|
||||
- 提醒接口真实数据库联调闭环
|
||||
- 配置与审计接口真实数据库联调闭环
|
||||
|
||||
### 仍未真正完成
|
||||
### 剩余外部事项
|
||||
|
||||
- 更完整的生产级权限拦截与数据范围控制(当前已完成统一鉴权入口、关键角色拦截、token 持久化失效以及订单/系统管理/报表/物流轨迹/AI 关键权限码校验,并已覆盖订单/客户/司机任务的基础数据范围控制)
|
||||
- 更多客户导入模板兼容
|
||||
- 第三方物流轨迹正式服务商联调与生产配置确认
|
||||
- 真实云上 OSS SDK 上传切换与生产环境联调确认
|
||||
- 最终生产环境参数替换与域名配置,已整理到 `docs/最终替换清单.md`
|
||||
|
||||
## 修正记录
|
||||
|
||||
@ -151,3 +150,4 @@
|
||||
| 2026-05-15 | 已继续完成司机任务大功能收口:补齐司机揽货至少一张照片校验、任务操作附件写入 `file_attachment` 真实留痕、文件接口统一记录 `created_by`,并为报表/文件接口补上真实鉴权;任务状态跟踪已同步将“司机任务接口”更新为“已完成”。 |
|
||||
| 2026-05-15 | 已继续收口系统管理高风险校验:补齐菜单父级非法与循环层级校验,避免菜单挂到自身或子级导致树结构异常;任务状态跟踪已同步更新系统管理接口说明。 |
|
||||
| 2026-05-15 | 已继续完成订单与审批大功能收口:补齐取消原因必填、审批结果合法性与驳回意见必填校验、发厂文案必填、订单详情物流任务/轨迹/附件聚合,以及订单创建/提交/取消/审批/取消审批/确认发厂等关键写操作审计日志;任务状态跟踪已同步将“订单接口”“审批接口”更新为“已完成”。 |
|
||||
| 2026-05-15 | 已完成最终验收版收口:补齐 `web-admin` 报表统计页、`web-sales` 真实提醒页、前端可配置 API 地址与微信端配置文件,整理 `docs/最终替换清单.md`,并完成后端冒烟与两个 Web 前端生产构建验证。 |
|
||||
|
||||
Loading…
Reference in New Issue
Block a user