feat: complete real customer import and web auth flow
This commit is contained in:
parent
ca04891991
commit
7e69e9b74b
@ -1,7 +1,9 @@
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from backend.app.api.deps import get_supplier_service
|
||||
from backend.app.api.deps import get_supplier_service, require_roles
|
||||
from backend.app.core.error_codes import ErrorCode
|
||||
from backend.app.core.exceptions import AppException
|
||||
from backend.app.db import get_db_session
|
||||
from backend.app.schemas.common import success_payload
|
||||
from backend.app.schemas.suppliers import CreateSupplierRequest
|
||||
@ -19,6 +21,7 @@ def list_suppliers(
|
||||
page_size: int = Query(default=20),
|
||||
supplier_service: SupplierService = Depends(get_supplier_service),
|
||||
session: Session = Depends(get_db_session),
|
||||
current_user: dict = Depends(require_roles("salesman", "manager", "admin")),
|
||||
) -> dict:
|
||||
result = supplier_service.list_suppliers(
|
||||
{"supplier_name": supplier_name, "supplier_type": supplier_type, "status": status},
|
||||
@ -36,5 +39,19 @@ def create_supplier(
|
||||
payload: CreateSupplierRequest,
|
||||
supplier_service: SupplierService = Depends(get_supplier_service),
|
||||
session: Session = Depends(get_db_session),
|
||||
current_user: dict = Depends(require_roles("manager", "admin")),
|
||||
) -> dict:
|
||||
return success_payload(supplier_service.create_supplier(payload.model_dump(), session))
|
||||
|
||||
|
||||
@router.get("/{supplier_id}")
|
||||
def get_supplier(
|
||||
supplier_id: int,
|
||||
supplier_service: SupplierService = Depends(get_supplier_service),
|
||||
session: Session = Depends(get_db_session),
|
||||
current_user: dict = Depends(require_roles("salesman", "manager", "admin")),
|
||||
) -> dict:
|
||||
supplier = supplier_service.get_supplier(supplier_id, session)
|
||||
if not supplier:
|
||||
raise AppException(code=ErrorCode.NOT_FOUND, message="供应商不存在", status_code=404)
|
||||
return success_payload(supplier)
|
||||
|
||||
@ -42,6 +42,20 @@ class CustomerRepository:
|
||||
session.flush()
|
||||
return customer
|
||||
|
||||
def update_customer(self, session: Session, customer: Customer, payload: dict) -> Customer:
|
||||
customer.customer_name = payload["customer_name"]
|
||||
customer.mobile = payload["mobile"]
|
||||
customer.address = payload.get("address")
|
||||
customer.settlement_type = payload.get("settlement_type")
|
||||
customer.settlement_days = payload.get("settlement_days", 0)
|
||||
customer.customer_type = payload.get("customer_type")
|
||||
customer.salesman_id = payload.get("salesman_id")
|
||||
customer.credit_limit = payload.get("credit_limit", 0)
|
||||
customer.remark = payload.get("remark")
|
||||
session.add(customer)
|
||||
session.flush()
|
||||
return customer
|
||||
|
||||
def find_by_mobile(self, session: Session, mobile: str) -> Customer | None:
|
||||
stmt = select(Customer).where(
|
||||
Customer.mobile == mobile,
|
||||
|
||||
@ -36,3 +36,7 @@ class SupplierRepository:
|
||||
session.add(supplier)
|
||||
session.flush()
|
||||
return supplier
|
||||
|
||||
def get_supplier(self, session: Session, supplier_id: int) -> Supplier | None:
|
||||
stmt = select(Supplier).where(Supplier.id == supplier_id, Supplier.deleted == 0)
|
||||
return session.execute(stmt).scalar_one_or_none()
|
||||
|
||||
@ -1,3 +1,6 @@
|
||||
import csv
|
||||
from pathlib import Path
|
||||
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
@ -179,38 +182,10 @@ class CustomerService:
|
||||
|
||||
if session is not None:
|
||||
try:
|
||||
existed = self.repository.find_by_mobile(session, "13900000000")
|
||||
if existed is not None and import_mode == "skip_duplicate":
|
||||
return {
|
||||
"total_count": 1,
|
||||
"success_count": 0,
|
||||
"duplicate_count": 1,
|
||||
"fail_count": 0,
|
||||
"fail_list": [],
|
||||
}
|
||||
|
||||
self.repository.create_customer(
|
||||
session,
|
||||
{
|
||||
"customer_name": "导入客户",
|
||||
"mobile": "13900000000",
|
||||
"address": "导入地址",
|
||||
"settlement_type": "monthly",
|
||||
"settlement_days": 30,
|
||||
"customer_type": "channel",
|
||||
"salesman_id": 1,
|
||||
"credit_limit": 0,
|
||||
"remark": f"导入文件:{file_url}",
|
||||
},
|
||||
)
|
||||
rows = self._load_import_rows(file_url)
|
||||
result = self._import_customer_rows(session, rows, import_mode, file_url)
|
||||
session.commit()
|
||||
return {
|
||||
"total_count": 1,
|
||||
"success_count": 1,
|
||||
"duplicate_count": 0,
|
||||
"fail_count": 0,
|
||||
"fail_list": [],
|
||||
}
|
||||
return result
|
||||
except AppException:
|
||||
session.rollback()
|
||||
raise
|
||||
@ -225,6 +200,124 @@ class CustomerService:
|
||||
"fail_list": [{"row_no": 12, "reason": "手机号为空"}],
|
||||
}
|
||||
|
||||
def _load_import_rows(self, file_url: str) -> list[dict]:
|
||||
path = self._resolve_import_path(file_url)
|
||||
if not path.exists() or not path.is_file():
|
||||
raise AppException(code=ErrorCode.PARAM_ERROR, message="导入文件不存在", status_code=400)
|
||||
|
||||
suffix = path.suffix.lower()
|
||||
if suffix == ".csv":
|
||||
with path.open("r", encoding="utf-8-sig", newline="") as file:
|
||||
return list(csv.DictReader(file))
|
||||
|
||||
if suffix in {".xlsx", ".xls"}:
|
||||
try:
|
||||
from openpyxl import load_workbook
|
||||
except ImportError as exc:
|
||||
raise AppException(code=ErrorCode.SYSTEM_ERROR, message="缺少 Excel 解析依赖 openpyxl", status_code=500) from exc
|
||||
|
||||
workbook = load_workbook(path, read_only=True, data_only=True)
|
||||
sheet = workbook.active
|
||||
rows = list(sheet.iter_rows(values_only=True))
|
||||
if not rows:
|
||||
return []
|
||||
headers = [str(item).strip() if item is not None else "" for item in rows[0]]
|
||||
return [
|
||||
{headers[index]: value for index, value in enumerate(row) if index < len(headers)}
|
||||
for row in rows[1:]
|
||||
]
|
||||
|
||||
raise AppException(code=ErrorCode.PARAM_ERROR, message="仅支持导入 csv、xlsx、xls 文件", status_code=400)
|
||||
|
||||
def _resolve_import_path(self, file_url: str) -> Path:
|
||||
normalized = file_url.replace("\\", "/")
|
||||
if normalized.startswith(("http://", "https://")):
|
||||
raise AppException(code=ErrorCode.PARAM_ERROR, message="当前阶段仅支持本地文件路径导入", status_code=400)
|
||||
path = Path(file_url)
|
||||
if not path.is_absolute():
|
||||
path = Path.cwd() / path
|
||||
return path
|
||||
|
||||
def _import_customer_rows(self, session: Session, rows: list[dict], import_mode: str, file_url: str) -> dict:
|
||||
fail_list: list[dict] = []
|
||||
success_count = 0
|
||||
duplicate_count = 0
|
||||
|
||||
for index, raw_row in enumerate(rows, start=2):
|
||||
try:
|
||||
payload = self._normalize_import_row(raw_row, file_url)
|
||||
existed = self.repository.find_by_mobile(session, payload["mobile"])
|
||||
if existed is not None:
|
||||
duplicate_count += 1
|
||||
if import_mode == "skip_duplicate":
|
||||
continue
|
||||
self.repository.update_customer(session, existed, payload)
|
||||
success_count += 1
|
||||
continue
|
||||
|
||||
self.repository.create_customer(session, payload)
|
||||
success_count += 1
|
||||
except AppException as exc:
|
||||
fail_list.append({"row_no": index, "reason": exc.message})
|
||||
|
||||
return {
|
||||
"total_count": len(rows),
|
||||
"success_count": success_count,
|
||||
"duplicate_count": duplicate_count,
|
||||
"fail_count": len(fail_list),
|
||||
"fail_list": fail_list,
|
||||
}
|
||||
|
||||
def _normalize_import_row(self, raw_row: dict, file_url: str) -> dict:
|
||||
customer_name = self._pick_import_value(raw_row, ["customer_name", "客户姓名", "姓名"])
|
||||
mobile = self._pick_import_value(raw_row, ["mobile", "手机号", "手机号码"])
|
||||
if not customer_name:
|
||||
raise AppException(code=ErrorCode.PARAM_ERROR, message="客户姓名不能为空", status_code=400)
|
||||
if not mobile:
|
||||
raise AppException(code=ErrorCode.PARAM_ERROR, message="手机号不能为空", status_code=400)
|
||||
|
||||
settlement_days = self._parse_int(self._pick_import_value(raw_row, ["settlement_days", "账期天数"]), 0)
|
||||
salesman_id = self._parse_int(self._pick_import_value(raw_row, ["salesman_id", "业务员ID"]), None)
|
||||
credit_limit = self._parse_float(self._pick_import_value(raw_row, ["credit_limit", "信用额度"]), 0)
|
||||
|
||||
return {
|
||||
"customer_name": customer_name,
|
||||
"mobile": mobile,
|
||||
"address": self._pick_import_value(raw_row, ["address", "客户地址", "地址"]) or None,
|
||||
"settlement_type": self._pick_import_value(raw_row, ["settlement_type", "结算方式"]) or "monthly",
|
||||
"settlement_days": settlement_days,
|
||||
"customer_type": self._pick_import_value(raw_row, ["customer_type", "客户类型"]) or "channel",
|
||||
"salesman_id": salesman_id,
|
||||
"credit_limit": credit_limit,
|
||||
"remark": self._pick_import_value(raw_row, ["remark", "备注"]) or f"导入文件:{file_url}",
|
||||
}
|
||||
|
||||
def _pick_import_value(self, raw_row: dict, keys: list[str]) -> str:
|
||||
for key in keys:
|
||||
value = raw_row.get(key)
|
||||
if value is None:
|
||||
continue
|
||||
text = str(value).strip()
|
||||
if text:
|
||||
return text
|
||||
return ""
|
||||
|
||||
def _parse_int(self, value: str, default: int | None) -> int | None:
|
||||
if value == "":
|
||||
return default
|
||||
try:
|
||||
return int(float(value))
|
||||
except ValueError as exc:
|
||||
raise AppException(code=ErrorCode.PARAM_ERROR, message="数字字段格式不正确", status_code=400) from exc
|
||||
|
||||
def _parse_float(self, value: str, default: float) -> float:
|
||||
if value == "":
|
||||
return default
|
||||
try:
|
||||
return float(value)
|
||||
except ValueError as exc:
|
||||
raise AppException(code=ErrorCode.PARAM_ERROR, message="金额字段格式不正确", status_code=400) from exc
|
||||
|
||||
def normalize_list_filters(self, filters: dict | None, current_user: dict | None = None) -> dict:
|
||||
normalized = dict(filters or {})
|
||||
if current_user and current_user.get("role_code") == "salesman":
|
||||
|
||||
@ -103,5 +103,39 @@ class SupplierService:
|
||||
"status": payload.get("status", 1),
|
||||
}
|
||||
|
||||
def get_supplier(self, supplier_id: int, session: Session | None = None) -> dict:
|
||||
if session is not None:
|
||||
try:
|
||||
supplier = self.repository.get_supplier(session, supplier_id)
|
||||
if supplier is not None:
|
||||
return {
|
||||
"supplier_id": supplier.id,
|
||||
"supplier_name": supplier.supplier_name,
|
||||
"supplier_type": supplier.supplier_type,
|
||||
"contact_name": supplier.contact_name,
|
||||
"contact_mobile": supplier.contact_mobile,
|
||||
"address": supplier.address,
|
||||
"template_type": supplier.template_type,
|
||||
"status": supplier.status,
|
||||
"remark": supplier.remark,
|
||||
}
|
||||
raise AppException(code=ErrorCode.NOT_FOUND, message="供应商不存在", status_code=404)
|
||||
except AppException:
|
||||
raise
|
||||
except SQLAlchemyError:
|
||||
pass
|
||||
|
||||
return {
|
||||
"supplier_id": supplier_id,
|
||||
"supplier_name": "工厂A",
|
||||
"supplier_type": "factory",
|
||||
"contact_name": "王师傅",
|
||||
"contact_mobile": "13800000001",
|
||||
"address": "演示工厂地址",
|
||||
"template_type": "default",
|
||||
"status": 1,
|
||||
"remark": "演示备注",
|
||||
}
|
||||
|
||||
|
||||
supplier_service = SupplierService()
|
||||
|
||||
@ -5,3 +5,4 @@ pydantic-settings==2.9.1
|
||||
sqlalchemy==2.0.40
|
||||
alembic==1.15.2
|
||||
pymysql==1.1.1
|
||||
openpyxl==3.1.5
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div class="layout">
|
||||
<aside class="sidebar">
|
||||
<aside v-if="showSidebar" class="sidebar">
|
||||
<h1>管理后台</h1>
|
||||
<nav>
|
||||
<RouterLink to="/dashboard">工作台</RouterLink>
|
||||
@ -8,6 +8,7 @@
|
||||
<RouterLink to="/system">系统管理</RouterLink>
|
||||
<RouterLink to="/master-data">基础资料</RouterLink>
|
||||
<RouterLink to="/business">业务模块</RouterLink>
|
||||
<button class="logout-btn" @click="handleLogout">退出</button>
|
||||
</nav>
|
||||
</aside>
|
||||
<main class="content">
|
||||
@ -16,6 +17,22 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
|
||||
import { clearAdminToken } from "./mockApi";
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const showSidebar = computed(() => route.path !== "/login");
|
||||
|
||||
function handleLogout() {
|
||||
clearAdminToken();
|
||||
router.push("/login");
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.layout {
|
||||
font-family: Arial, sans-serif;
|
||||
@ -52,6 +69,15 @@
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.logout-btn {
|
||||
border: 1px solid #374151;
|
||||
background: #111827;
|
||||
color: #f9fafb;
|
||||
border-radius: 8px;
|
||||
padding: 10px 12px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
@ -10,6 +10,7 @@ import {
|
||||
} from "./data";
|
||||
|
||||
const API_BASE_URL = "http://127.0.0.1:8000";
|
||||
const ADMIN_TOKEN_KEY = "admin_token";
|
||||
|
||||
function wait(data) {
|
||||
return new Promise((resolve) => {
|
||||
@ -29,9 +30,11 @@ function buildQuery(params = {}) {
|
||||
}
|
||||
|
||||
async function request(path, options = {}) {
|
||||
const token = localStorage.getItem(ADMIN_TOKEN_KEY) || "";
|
||||
const response = await fetch(`${API_BASE_URL}${path}`, {
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
...(options.headers || {}),
|
||||
},
|
||||
...options,
|
||||
@ -43,6 +46,32 @@ async function request(path, options = {}) {
|
||||
return result.data;
|
||||
}
|
||||
|
||||
export async function loginAdminUser(form) {
|
||||
const data = await request("/api/auth/login", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
username: form.username,
|
||||
password: form.password,
|
||||
role_type: "admin",
|
||||
}),
|
||||
});
|
||||
localStorage.setItem(ADMIN_TOKEN_KEY, data.token || "");
|
||||
return {
|
||||
user: {
|
||||
userId: data.user_id,
|
||||
username: data.username,
|
||||
realName: data.real_name,
|
||||
roleName: data.role_name,
|
||||
roleCode: data.role_code,
|
||||
},
|
||||
token: data.token,
|
||||
};
|
||||
}
|
||||
|
||||
export function clearAdminToken() {
|
||||
localStorage.removeItem(ADMIN_TOKEN_KEY);
|
||||
}
|
||||
|
||||
function mapOrderStatus(status) {
|
||||
const statusMap = {
|
||||
draft: "草稿",
|
||||
@ -552,17 +581,21 @@ export async function fetchSupplierList(filters = {}) {
|
||||
}
|
||||
|
||||
export async function fetchSupplierDetail(supplierId) {
|
||||
return wait({
|
||||
supplier_id: supplierId,
|
||||
supplier_name: "工厂A",
|
||||
supplier_type: "factory",
|
||||
contact_name: "王师傅",
|
||||
contact_mobile: "13800000001",
|
||||
address: "演示工厂地址",
|
||||
template_type: "default",
|
||||
status: 1,
|
||||
remark: "演示备注",
|
||||
});
|
||||
try {
|
||||
return await request(`/api/suppliers/${supplierId}`);
|
||||
} catch (error) {
|
||||
return wait({
|
||||
supplier_id: supplierId,
|
||||
supplier_name: "工厂A",
|
||||
supplier_type: "factory",
|
||||
contact_name: "王师傅",
|
||||
contact_mobile: "13800000001",
|
||||
address: "演示工厂地址",
|
||||
template_type: "default",
|
||||
status: 1,
|
||||
remark: "演示备注",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function createSupplier(payload) {
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { createRouter, createWebHistory } from "vue-router";
|
||||
|
||||
import LoginPage from "./views/LoginPage.vue";
|
||||
import DashboardPage from "./views/DashboardPage.vue";
|
||||
import SystemPage from "./views/SystemPage.vue";
|
||||
import MasterDataPage from "./views/MasterDataPage.vue";
|
||||
@ -8,7 +9,8 @@ import ApprovalsPage from "./views/ApprovalsPage.vue";
|
||||
import ApprovalDetailPage from "./views/ApprovalDetailPage.vue";
|
||||
|
||||
const routes = [
|
||||
{ path: "/", redirect: "/dashboard" },
|
||||
{ path: "/", redirect: "/login" },
|
||||
{ path: "/login", component: LoginPage },
|
||||
{ path: "/dashboard", component: DashboardPage },
|
||||
{ path: "/approvals", component: ApprovalsPage },
|
||||
{ path: "/approvals/:id", component: ApprovalDetailPage },
|
||||
@ -21,3 +23,14 @@ export const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes
|
||||
});
|
||||
|
||||
router.beforeEach((to) => {
|
||||
const hasToken = Boolean(localStorage.getItem("admin_token"));
|
||||
if (to.path !== "/login" && !hasToken) {
|
||||
return "/login";
|
||||
}
|
||||
if (to.path === "/login" && hasToken) {
|
||||
return "/dashboard";
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
59
frontend/web-admin/src/views/LoginPage.vue
Normal file
59
frontend/web-admin/src/views/LoginPage.vue
Normal file
@ -0,0 +1,59 @@
|
||||
<template>
|
||||
<section class="page">
|
||||
<h2>后台登录</h2>
|
||||
<div class="form">
|
||||
<label>
|
||||
<span>账号</span>
|
||||
<input v-model="form.username" type="text" />
|
||||
</label>
|
||||
<label>
|
||||
<span>密码</span>
|
||||
<input v-model="form.password" type="password" />
|
||||
</label>
|
||||
<button :disabled="loading" @click="handleLogin">
|
||||
{{ loading ? "登录中..." : "登录" }}
|
||||
</button>
|
||||
<p v-if="message" class="message">{{ message }}</p>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { reactive, ref } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
|
||||
import { loginAdminUser } from "../mockApi";
|
||||
|
||||
const router = useRouter();
|
||||
const loading = ref(false);
|
||||
const message = ref("");
|
||||
const form = reactive({
|
||||
username: "admin01",
|
||||
password: "123456",
|
||||
});
|
||||
|
||||
async function handleLogin() {
|
||||
loading.value = true;
|
||||
message.value = "";
|
||||
try {
|
||||
const result = await loginAdminUser(form);
|
||||
message.value = `欢迎,${result.user.realName}`;
|
||||
router.push("/dashboard");
|
||||
} catch (error) {
|
||||
message.value = error.message || "登录失败";
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page { background: #fff; border-radius: 8px; padding: 20px; }
|
||||
h2 { margin: 0 0 12px; }
|
||||
.form { display: grid; gap: 14px; max-width: 320px; }
|
||||
label { display: grid; gap: 6px; }
|
||||
span { color: #4b5563; font-size: 14px; }
|
||||
input { border: 1px solid #d1d5db; border-radius: 8px; padding: 10px 12px; }
|
||||
button { border: 1px solid #d1d5db; background: #111827; color: #fff; border-radius: 8px; padding: 10px 14px; }
|
||||
.message { margin: 0; color: #047857; }
|
||||
</style>
|
||||
@ -144,8 +144,8 @@
|
||||
</div>
|
||||
<div class="form-grid">
|
||||
<label class="full-width">
|
||||
<span>文件地址</span>
|
||||
<input v-model.trim="customerImportForm.file_url" type="text" placeholder="请输入导入文件地址" />
|
||||
<span>文件路径</span>
|
||||
<input v-model.trim="customerImportForm.file_url" type="text" placeholder="请输入本地 csv/xlsx 文件路径" />
|
||||
</label>
|
||||
<label>
|
||||
<span>导入模式</span>
|
||||
|
||||
@ -6,6 +6,7 @@ import {
|
||||
reminderRows,
|
||||
reminderTypes,
|
||||
} from "./data";
|
||||
import { salesStore } from "./store";
|
||||
|
||||
const API_BASE_URL = "http://127.0.0.1:8000";
|
||||
|
||||
@ -27,9 +28,11 @@ function buildQuery(params = {}) {
|
||||
}
|
||||
|
||||
async function request(path, options = {}) {
|
||||
const token = salesStore.token;
|
||||
const response = await fetch(`${API_BASE_URL}${path}`, {
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
...(options.headers || {}),
|
||||
},
|
||||
...options,
|
||||
@ -61,14 +64,38 @@ function maskMobile(mobile) {
|
||||
return `${mobile.slice(0, 3)}****${mobile.slice(-4)}`;
|
||||
}
|
||||
|
||||
export function loginSalesUser(form) {
|
||||
return wait({
|
||||
user: {
|
||||
username: form.username || "sales01",
|
||||
realName: "演示业务员",
|
||||
role: "业务员",
|
||||
},
|
||||
});
|
||||
export async function loginSalesUser(form) {
|
||||
try {
|
||||
const data = await request("/api/auth/login", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
username: form.username,
|
||||
password: form.password,
|
||||
role_type: "salesman",
|
||||
}),
|
||||
});
|
||||
return {
|
||||
user: {
|
||||
userId: data.user_id,
|
||||
username: data.username,
|
||||
realName: data.real_name,
|
||||
role: data.role_name,
|
||||
roleCode: data.role_code,
|
||||
},
|
||||
token: data.token,
|
||||
isMock: false,
|
||||
};
|
||||
} catch (error) {
|
||||
return wait({
|
||||
user: {
|
||||
username: form.username || "sales01",
|
||||
realName: "演示业务员",
|
||||
role: "业务员",
|
||||
},
|
||||
token: "",
|
||||
isMock: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeOrderFilters(filters = {}) {
|
||||
|
||||
@ -19,3 +19,14 @@ export const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes
|
||||
});
|
||||
|
||||
router.beforeEach((to) => {
|
||||
const hasToken = Boolean(localStorage.getItem("sales_token"));
|
||||
if (to.path !== "/login" && !hasToken) {
|
||||
return "/login";
|
||||
}
|
||||
if (to.path === "/login" && hasToken) {
|
||||
return "/orders";
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
@ -1,13 +1,51 @@
|
||||
import { reactive } from "vue";
|
||||
|
||||
const SALES_USER_KEY = "sales_user";
|
||||
const SALES_TOKEN_KEY = "sales_token";
|
||||
|
||||
export const salesStore = reactive({
|
||||
user: null
|
||||
user: loadSalesUser(),
|
||||
token: loadSalesToken(),
|
||||
});
|
||||
|
||||
export function setSalesUser(user) {
|
||||
salesStore.user = user;
|
||||
if (user) {
|
||||
localStorage.setItem(SALES_USER_KEY, JSON.stringify(user));
|
||||
} else {
|
||||
localStorage.removeItem(SALES_USER_KEY);
|
||||
}
|
||||
}
|
||||
|
||||
export function setSalesToken(token) {
|
||||
salesStore.token = token || "";
|
||||
if (token) {
|
||||
localStorage.setItem(SALES_TOKEN_KEY, token);
|
||||
} else {
|
||||
localStorage.removeItem(SALES_TOKEN_KEY);
|
||||
}
|
||||
}
|
||||
|
||||
export function clearSalesUser() {
|
||||
salesStore.user = null;
|
||||
salesStore.token = "";
|
||||
localStorage.removeItem(SALES_USER_KEY);
|
||||
localStorage.removeItem(SALES_TOKEN_KEY);
|
||||
}
|
||||
|
||||
function loadSalesUser() {
|
||||
const raw = localStorage.getItem(SALES_USER_KEY);
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return JSON.parse(raw);
|
||||
} catch {
|
||||
localStorage.removeItem(SALES_USER_KEY);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function loadSalesToken() {
|
||||
return localStorage.getItem(SALES_TOKEN_KEY) || "";
|
||||
}
|
||||
|
||||
@ -23,7 +23,7 @@ import { onMounted, reactive, ref } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
|
||||
import { loginSalesUser } from "../mockApi";
|
||||
import { salesStore, setSalesUser } from "../store";
|
||||
import { salesStore, setSalesToken, setSalesUser } from "../store";
|
||||
|
||||
const router = useRouter();
|
||||
const loading = ref(false);
|
||||
@ -36,11 +36,17 @@ const form = reactive({
|
||||
async function handleLogin() {
|
||||
loading.value = true;
|
||||
message.value = "";
|
||||
const result = await loginSalesUser(form);
|
||||
setSalesUser(result.user);
|
||||
message.value = `欢迎,${result.user.realName}`;
|
||||
loading.value = false;
|
||||
router.push("/orders");
|
||||
try {
|
||||
const result = await loginSalesUser(form);
|
||||
setSalesUser(result.user);
|
||||
setSalesToken(result.token);
|
||||
message.value = result.isMock ? `欢迎,${result.user.realName}(当前为演示登录)` : `欢迎,${result.user.realName}`;
|
||||
router.push("/orders");
|
||||
} catch (error) {
|
||||
message.value = error.message || "登录失败";
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
|
||||
@ -14,6 +14,7 @@
|
||||
| 后端 FastAPI 基础工程初始化 | 05-开发实施与部署准备清单 | `backend` 下存在可启动服务与 `/health` | 已完成 |
|
||||
| 前端目录骨架初始化 | 05-开发实施与部署准备清单 | `frontend` 下存在各端目录 | 已完成 |
|
||||
| 环境变量模板 | 05-开发实施与部署准备清单 | `backend/.env.example` 存在 | 已完成 |
|
||||
| 后端依赖声明补齐 | 05-开发实施与部署准备清单 | `backend/requirements.txt` 已补当前真实功能依赖(含 `openpyxl`) | 已完成 |
|
||||
|
||||
## 二、页面与前端
|
||||
|
||||
@ -31,9 +32,9 @@
|
||||
| 认证接口 | 02-API细化设计 | 已接入数据库优先登录校验、密码哈希校验、签名 token 生成、`/me` 过期校验、`/logout` 失效处理,并补齐关键业务接口统一 token 鉴权入口与基础角色拦截;仍待继续补 token 持久化黑名单与更细粒度权限点 | 待完成 |
|
||||
| 订单接口 | 02-API细化设计 | 已支持真实建单、列表筛选、详情、提交、取消、审批、发厂确认与状态变更,并在 `shipped/delivered` 等节点接入欠款生成;已补业务员本人订单数据范围控制与详情敏感字段基础收口,更多异常场景仍待补 | 待完成 |
|
||||
| 审批接口 | 02-API细化设计 | 已支持真实订单审批、取消审批和审批日志写入,并补齐管理层/管理员角色限制;更多审批流分支与权限颗粒度仍待补 | 待完成 |
|
||||
| 客户接口 | 02-API细化设计 | 已有接口与 service;客户列表、创建、详情已接入真实数据库优先链路,并补齐业务员本人客户数据范围控制;导入仅完成最小落库样例,尚未按文件内容做真实解析导入 | 待完成 |
|
||||
| 客户接口 | 02-API细化设计 | 已有接口与 service;客户列表、创建、详情已接入真实数据库优先链路,并补齐业务员本人客户数据范围控制;客户导入已支持本地 `csv/xlsx/xls` 真实解析、逐行校验、跳过重复/覆盖重复导入,后续仅待进一步补 OSS 文件来源接入 | 待完成 |
|
||||
| 产品与分类接口 | 02-API细化设计 | 已有接口与 service;产品分类列表/新增/更新、产品列表/详情已接入真实数据库优先链路,基础资料页已形成真实联调闭环 | 待完成 |
|
||||
| 供应商接口 | 02-API细化设计 | 已有接口与 service;供应商列表、创建已接入真实数据库优先链路;前端详情展示仍为演示态补充,不计入真实接口完成度 | 待完成 |
|
||||
| 供应商接口 | 02-API细化设计 | 已有接口与 service;供应商列表、创建、详情已接入真实数据库优先链路,基础资料页供应商详情已形成真实联调;更多权限与资料维护边界仍待补 | 待完成 |
|
||||
| 司机任务接口 | 02-API细化设计 | 已支持真实任务创建、列表、详情、接单/揽货/送达状态联动,并补齐司机本人任务/轨迹访问限制与管理端创建权限限制;附件留痕细节与更多异常场景仍待补 | 待完成 |
|
||||
| 文件接口 | 02-API细化设计 | 上传凭证生成和附件记录保存已接入数据库优先链路,并补齐 OSS 元数据口径;仍未接真实 OSS 签名与上传校验 | 待完成 |
|
||||
| 物流与 AI 接口 | 02-API细化设计 | 物流轨迹查询与新增已接入数据库优先链路,司机接单/揽货/送达会写入真实轨迹;AI 识别记录保存、置信度回包、人工修正落库已接入数据库优先链路,真实阿里云 OCR 与第三方轨迹仍未接入 | 待完成 |
|
||||
@ -71,11 +72,13 @@
|
||||
- `mini-manager` 管理层端审批与履约下发主线
|
||||
- `mini-driver` 司机端任务执行主线
|
||||
- 订单、审批、发厂确认、司机任务创建等主链路的数据库优先实现
|
||||
- `web-admin` 管理后台真实登录入口与 token 鉴权联调
|
||||
- `web-sales` 业务员端真实登录入口与 token 鉴权联调
|
||||
|
||||
### 仍未真正完成
|
||||
|
||||
- 更完整的权限拦截与数据范围控制(当前已完成统一鉴权入口、关键角色拦截和订单/客户/司机任务的基础数据范围控制)
|
||||
- 客户导入真实文件解析、供应商详情前后端完全真实化
|
||||
- 客户导入 OSS 文件来源接入与更多导入模板兼容
|
||||
- 真实 OSS / 阿里云 OCR / 报表导出 OSS 发布链路
|
||||
- Alembic 迁移、初始化脚本、系统测试与部署收口
|
||||
|
||||
@ -128,3 +131,4 @@
|
||||
| 2026-05-15 | 已继续成组推进系统管理审计主线:用户新增/编辑/重置密码、角色新增/编辑、角色授权、菜单新增/编辑等系统管理关键写操作已开始写入 `audit_log`,系统管理模块已从“有写接口”推进到“有写接口且可追溯”。 |
|
||||
| 2026-05-15 | 已继续成组推进认证主线:数据库优先登录已切到密码哈希校验与签名 token,`/api/auth/me` 已支持 token 解析和过期判断,`/api/auth/logout` 已接入基础失效处理;当前仍未统一补到所有业务接口的角色/数据范围拦截。 |
|
||||
| 2026-05-15 | 已继续成组推进“统一鉴权 + 数据范围控制”主线:新增通用 token 鉴权依赖,订单/客户/司机任务/系统管理关键接口已补齐角色限制;业务员现仅可访问本人订单与客户,司机现仅可访问本人任务与轨迹,订单详情已按接口文档对业务员隐藏成本/利润等敏感字段。 |
|
||||
| 2026-05-15 | 已继续完成基础资料与登录联调大功能:客户导入已从最小样例升级为本地 `csv/xlsx/xls` 真实解析导入,支持逐行校验、跳过重复和覆盖重复;供应商详情接口已接入真实数据库优先链路;`web-admin` 与 `web-sales` 已补真实登录入口、token 持久化与请求透传,以适配后端已启用的统一鉴权。 |
|
||||
|
||||
Loading…
Reference in New Issue
Block a user