feat: finalize delivery build and replacement checklist

This commit is contained in:
taiyi 2026-05-15 15:06:10 +08:00
parent 4f35ef9dd0
commit f1e44178f8
23 changed files with 3268 additions and 19 deletions

119
docs/最终替换清单.md Normal file
View 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. 小程序验收:管理层审批、司机接单/揽货/送达

View File

@ -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 正式生产配置

View File

@ -1,7 +1,9 @@
const { apiBaseUrl } = require("./config");
App({
globalData: {
appName: "mini-driver",
apiBaseUrl: "http://127.0.0.1:8000",
apiBaseUrl,
authToken: "",
driverUser: null,
},

View File

@ -0,0 +1,5 @@
const apiBaseUrl = "http://127.0.0.1:8000";
module.exports = {
apiBaseUrl,
};

View File

@ -1,7 +1,9 @@
const { apiBaseUrl } = require("./config");
App({
globalData: {
appName: "mini-manager",
apiBaseUrl: "http://127.0.0.1:8000",
apiBaseUrl,
authToken: "",
managerUser: null,
},

View File

@ -0,0 +1,5 @@
const apiBaseUrl = "http://127.0.0.1:8000";
module.exports = {
apiBaseUrl,
};

View 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

File diff suppressed because it is too large Load Diff

View File

@ -12,6 +12,7 @@
"vue-router": "^4.5.1"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.2.4",
"vite": "^6.3.5"
}
}

View File

@ -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>

View 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";

View File

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

View File

@ -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({

View 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>

View File

@ -1,3 +1,6 @@
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
export default defineConfig({});
export default defineConfig({
plugins: [vue()],
});

View 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

File diff suppressed because it is too large Load Diff

View File

@ -12,6 +12,7 @@
"vue-router": "^4.5.1"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.2.4",
"vite": "^6.3.5"
}
}

View 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";

View File

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

View File

@ -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; }

View File

@ -1,3 +1,6 @@
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
export default defineConfig({});
export default defineConfig({
plugins: [vue()],
});

View File

@ -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 前端生产构建验证。 |