后台
This commit is contained in:
+26
@@ -0,0 +1,26 @@
|
|||||||
|
# ===== 安全:绝不入库 =====
|
||||||
|
# 生产密钥 / 数据库备份目录(alibb-secrets.txt、*.sql、*.sql.gz、dist 包等)
|
||||||
|
backups/
|
||||||
|
# 真实环境变量(入库用 .env.example 作模板)
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
|
||||||
|
# ===== 依赖与构建产物 =====
|
||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
dist-ssr/
|
||||||
|
*.tsbuildinfo
|
||||||
|
.vite/
|
||||||
|
coverage/
|
||||||
|
|
||||||
|
# ===== 日志与临时文件 =====
|
||||||
|
*.log
|
||||||
|
*.tmp
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# ===== 编辑器 =====
|
||||||
|
.idea/
|
||||||
|
# 注:不在根级忽略 .vscode/ —— frontend 需要提交 .vscode/extensions.json,
|
||||||
|
# 目录级忽略会让子目录 .gitignore 的 ! 例外失效
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
*.log
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
tests
|
||||||
|
vitest.config.ts
|
||||||
|
README.md
|
||||||
|
Dockerfile
|
||||||
|
docker-compose.yml
|
||||||
|
.dockerignore
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
DATABASE_URL="mysql://root:root@localhost:3306/inquiry"
|
||||||
|
INGEST_TOKEN="dev-ingest-token-1234"
|
||||||
|
JWT_SECRET="dev-jwt-secret-1234"
|
||||||
|
JWT_EXPIRES_IN="7d"
|
||||||
|
ADMIN_USERNAME="admin"
|
||||||
|
ADMIN_PASSWORD="admin-password-1234"
|
||||||
|
PORT=8080
|
||||||
|
NODE_ENV=development
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
FROM node:20-bookworm-slim AS build
|
||||||
|
WORKDIR /app
|
||||||
|
RUN apt-get update -y \
|
||||||
|
&& apt-get install -y --no-install-recommends openssl ca-certificates \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm ci
|
||||||
|
COPY . .
|
||||||
|
RUN npx prisma generate && npm run build
|
||||||
|
|
||||||
|
# runtime:仅装 production 依赖,大幅减小镜像体积、减少 CVE 暴露面
|
||||||
|
FROM node:20-bookworm-slim
|
||||||
|
WORKDIR /app
|
||||||
|
RUN apt-get update -y \
|
||||||
|
&& apt-get install -y --no-install-recommends openssl ca-certificates \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm ci --omit=dev
|
||||||
|
# prisma 在 dependencies,npm ci 保留 CLI;生成的 client(.prisma)需从 build 阶段拷过来
|
||||||
|
COPY --from=build /app/node_modules/.prisma ./node_modules/.prisma
|
||||||
|
COPY --from=build /app/dist ./dist
|
||||||
|
COPY --from=build /app/prisma ./prisma
|
||||||
|
COPY --from=build /app/package.json .
|
||||||
|
EXPOSE 8080
|
||||||
|
CMD ["sh","-c","npx prisma migrate deploy && node dist/server.js"]
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
services:
|
||||||
|
mysql:
|
||||||
|
image: mysql:8
|
||||||
|
environment:
|
||||||
|
MYSQL_ROOT_PASSWORD: root
|
||||||
|
MYSQL_DATABASE: inquiry
|
||||||
|
# utf8mb4 + UTC(容器内 MySQL 一律按 UTC 存,应用层负责显示侧时区)
|
||||||
|
command: >
|
||||||
|
--max_allowed_packet=128M
|
||||||
|
--character-set-server=utf8mb4
|
||||||
|
--default-time-zone=+00:00
|
||||||
|
volumes:
|
||||||
|
- mysql_data:/var/lib/mysql
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-uroot", "-proot"]
|
||||||
|
interval: 5s
|
||||||
|
retries: 20
|
||||||
|
backend:
|
||||||
|
build: .
|
||||||
|
environment:
|
||||||
|
# timezone=Z 让 mysql2 driver 也按 UTC 解释时间,避免 JS Date 双重偏移
|
||||||
|
DATABASE_URL: mysql://root:root@mysql:3306/inquiry?timezone=Z
|
||||||
|
INGEST_TOKEN: dev-ingest-token-1234
|
||||||
|
JWT_SECRET: dev-jwt-secret-1234
|
||||||
|
ADMIN_USERNAME: admin
|
||||||
|
ADMIN_PASSWORD: admin-password-1234
|
||||||
|
NODE_ENV: production
|
||||||
|
PORT: "8080"
|
||||||
|
depends_on:
|
||||||
|
mysql:
|
||||||
|
condition: service_healthy
|
||||||
|
ports:
|
||||||
|
- "8080:8080"
|
||||||
|
# nginx: serves the Vue3 frontend (built from ../frontend) on :80 and
|
||||||
|
# reverse-proxies /api/* -> http://backend:8080 (joins the default network so
|
||||||
|
# it resolves the `backend` service by name). Task 8.
|
||||||
|
# 启动: docker compose up -d nginx
|
||||||
|
nginx:
|
||||||
|
build:
|
||||||
|
context: ../frontend
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
depends_on:
|
||||||
|
- backend
|
||||||
|
ports:
|
||||||
|
- "8088:80"
|
||||||
|
# 一次性备份服务;默认不启动,手动触发:
|
||||||
|
# docker compose --profile backup run --rm backup
|
||||||
|
# 或宿主 crontab:0 3 * * * cd /path && docker compose --profile backup run --rm backup
|
||||||
|
backup:
|
||||||
|
image: mysql:8
|
||||||
|
profiles: ["backup"]
|
||||||
|
depends_on:
|
||||||
|
mysql:
|
||||||
|
condition: service_healthy
|
||||||
|
environment:
|
||||||
|
MYSQL_PWD: root
|
||||||
|
volumes:
|
||||||
|
- ./backups:/backup
|
||||||
|
- ./scripts/backup.sh:/backup.sh:ro
|
||||||
|
entrypoint: ["/bin/sh", "/backup.sh"]
|
||||||
|
volumes:
|
||||||
|
mysql_data: {}
|
||||||
Generated
+5723
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,47 @@
|
|||||||
|
{
|
||||||
|
"name": "inquiry-backend",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "",
|
||||||
|
"main": "index.js",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "tsx watch src/server.ts",
|
||||||
|
"build": "tsc",
|
||||||
|
"start": "node dist/server.js",
|
||||||
|
"test": "vitest run",
|
||||||
|
"db:migrate": "prisma migrate dev",
|
||||||
|
"db:deploy": "prisma migrate deploy",
|
||||||
|
"db:generate": "prisma generate"
|
||||||
|
},
|
||||||
|
"keywords": [],
|
||||||
|
"author": "",
|
||||||
|
"license": "ISC",
|
||||||
|
"type": "module",
|
||||||
|
"dependencies": {
|
||||||
|
"@prisma/client": "^5.22.0",
|
||||||
|
"bcryptjs": "^2.4.3",
|
||||||
|
"cors": "^2.8.6",
|
||||||
|
"dotenv": "^16.6.1",
|
||||||
|
"express": "^4.22.2",
|
||||||
|
"express-rate-limit": "^7.5.1",
|
||||||
|
"jsonwebtoken": "^9.0.3",
|
||||||
|
"morgan": "^1.11.0",
|
||||||
|
"mysql2": "^3.22.6",
|
||||||
|
"prisma": "^5.22.0",
|
||||||
|
"zod": "^3.25.76"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@testcontainers/mysql": "^10.28.0",
|
||||||
|
"@types/bcryptjs": "^2.4.6",
|
||||||
|
"@types/cors": "^2.8.19",
|
||||||
|
"@types/express": "^4.17.25",
|
||||||
|
"@types/jsonwebtoken": "^9.0.10",
|
||||||
|
"@types/morgan": "^1.9.10",
|
||||||
|
"@types/node": "^20.19.43",
|
||||||
|
"@types/supertest": "^6.0.3",
|
||||||
|
"supertest": "^7.2.2",
|
||||||
|
"testcontainers": "^10.28.0",
|
||||||
|
"tsx": "^4.23.1",
|
||||||
|
"typescript": "^5.9.3",
|
||||||
|
"vitest": "^2.1.9"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `customers` (
|
||||||
|
`id` BIGINT NOT NULL AUTO_INCREMENT,
|
||||||
|
`customer_id` VARCHAR(64) NOT NULL,
|
||||||
|
`name` VARCHAR(128) NULL,
|
||||||
|
`country` VARCHAR(64) NULL,
|
||||||
|
`email` VARCHAR(128) NULL,
|
||||||
|
`company` VARCHAR(255) NULL,
|
||||||
|
`register_time` DATETIME(3) NULL,
|
||||||
|
`avatar_url` VARCHAR(512) NULL,
|
||||||
|
`buyer_tags` VARCHAR(255) NULL,
|
||||||
|
`buyer_features` VARCHAR(255) NULL,
|
||||||
|
`behavior_product_views` INTEGER NULL,
|
||||||
|
`behavior_valid_inquiries` INTEGER NULL,
|
||||||
|
`behavior_valid_rfq` INTEGER NULL,
|
||||||
|
`behavior_login_days` INTEGER NULL,
|
||||||
|
`behavior_spam_inquiries` INTEGER NULL,
|
||||||
|
`behavior_top_industry` VARCHAR(255) NULL,
|
||||||
|
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
`updated_at` DATETIME(3) NOT NULL,
|
||||||
|
|
||||||
|
UNIQUE INDEX `customers_customer_id_key`(`customer_id`),
|
||||||
|
INDEX `customers_country_idx`(`country`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `inquiries` (
|
||||||
|
`id` BIGINT NOT NULL AUTO_INCREMENT,
|
||||||
|
`inquiry_id` VARCHAR(32) NOT NULL,
|
||||||
|
`customer_id` VARCHAR(64) NULL,
|
||||||
|
`manager` VARCHAR(128) NULL,
|
||||||
|
`create_time` DATETIME(3) NOT NULL,
|
||||||
|
`update_time` DATETIME(3) NOT NULL,
|
||||||
|
`status` VARCHAR(64) NULL,
|
||||||
|
`msg_preview` VARCHAR(512) NULL,
|
||||||
|
`is_flagged` BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
`messages_count` INTEGER NOT NULL DEFAULT 0,
|
||||||
|
`last_send_time` DATETIME(3) NULL,
|
||||||
|
`has_failed_batches` BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
`updated_at` DATETIME(3) NOT NULL,
|
||||||
|
|
||||||
|
UNIQUE INDEX `inquiries_inquiry_id_key`(`inquiry_id`),
|
||||||
|
INDEX `inquiries_customer_id_idx`(`customer_id`),
|
||||||
|
INDEX `inquiries_manager_idx`(`manager`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `chat_messages` (
|
||||||
|
`id` BIGINT NOT NULL AUTO_INCREMENT,
|
||||||
|
`message_id` VARCHAR(64) NOT NULL,
|
||||||
|
`inquiry_id` VARCHAR(32) NOT NULL,
|
||||||
|
`sender` VARCHAR(128) NULL,
|
||||||
|
`direction` ENUM('incoming', 'outgoing', 'system') NOT NULL,
|
||||||
|
`send_time` DATETIME(3) NOT NULL,
|
||||||
|
`content` MEDIUMTEXT NOT NULL,
|
||||||
|
`translation` TEXT NULL,
|
||||||
|
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
|
||||||
|
UNIQUE INDEX `chat_messages_message_id_key`(`message_id`),
|
||||||
|
INDEX `chat_messages_inquiry_id_send_time_message_id_idx`(`inquiry_id`, `send_time`, `message_id`),
|
||||||
|
INDEX `chat_messages_inquiry_id_idx`(`inquiry_id`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `users` (
|
||||||
|
`id` BIGINT NOT NULL AUTO_INCREMENT,
|
||||||
|
`username` VARCHAR(191) NOT NULL,
|
||||||
|
`password_hash` VARCHAR(191) NOT NULL,
|
||||||
|
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
|
||||||
|
UNIQUE INDEX `users_username_key`(`username`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `inquiries` ADD CONSTRAINT `inquiries_customer_id_fkey` FOREIGN KEY (`customer_id`) REFERENCES `customers`(`customer_id`) ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `chat_messages` ADD CONSTRAINT `chat_messages_inquiry_id_fkey` FOREIGN KEY (`inquiry_id`) REFERENCES `inquiries`(`inquiry_id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable: 客户行为新增「被加为黑名单数」(对应 alibaba 详情页可见字段)
|
||||||
|
ALTER TABLE `customers` ADD COLUMN `behavior_blacklist` INTEGER NULL;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable: 客户「最近询盘产品」图片 URL 列表(JSON 字符串)
|
||||||
|
ALTER TABLE `customers` ADD COLUMN `recent_products_images` TEXT NULL;
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
-- AlterTable: 询盘新增「该会话所有询盘号+创建时间」(allInquiries,JSON 字符串)
|
||||||
|
-- 用于「一会话多询盘」合并:同 secTradeId 只存 1 条,all_inquiries 记录该会话全部询盘。
|
||||||
|
ALTER TABLE `inquiries` ADD COLUMN `all_inquiries` TEXT NULL;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable: 客户手机号(alibaba 详情展开后可见的「手机」字段,部分客户有)
|
||||||
|
ALTER TABLE `customers` ADD COLUMN `phone` VARCHAR(64) NULL;
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
-- 店内行为:询盘产品名 + 数量 + 浏览产品(图/链接/ID)
|
||||||
|
ALTER TABLE `customers` ADD COLUMN `store_inquiry_product` VARCHAR(512) NULL;
|
||||||
|
ALTER TABLE `customers` ADD COLUMN `store_inquiry_qty` VARCHAR(32) NULL;
|
||||||
|
ALTER TABLE `customers` ADD COLUMN `store_visit_products` TEXT NULL;
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
-- 客户基本信息扩展(折叠面板展开后的字段):座机/商业类型/销售平台/公司官网
|
||||||
|
ALTER TABLE `customers` ADD COLUMN `landline` VARCHAR(32) NULL;
|
||||||
|
ALTER TABLE `customers` ADD COLUMN `business_type` VARCHAR(128) NULL;
|
||||||
|
ALTER TABLE `customers` ADD COLUMN `sales_platform` VARCHAR(255) NULL;
|
||||||
|
ALTER TABLE `customers` ADD COLUMN `website` VARCHAR(512) NULL;
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
-- 加宽 customer_id(原 VarChar(64) 放不下 sec_ 兜底的 secTradeId 会话标识)
|
||||||
|
ALTER TABLE `customers` MODIFY COLUMN `customer_id` VARCHAR(256) NOT NULL;
|
||||||
|
ALTER TABLE `inquiries` MODIFY COLUMN `customer_id` VARCHAR(256);
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
# Please do not edit this file manually
|
||||||
|
# It should be added in your version-control system (i.e. Git)
|
||||||
|
provider = "mysql"
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
generator client {
|
||||||
|
provider = "prisma-client-js"
|
||||||
|
}
|
||||||
|
|
||||||
|
datasource db {
|
||||||
|
provider = "mysql"
|
||||||
|
url = env("DATABASE_URL")
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Direction {
|
||||||
|
incoming
|
||||||
|
outgoing
|
||||||
|
system
|
||||||
|
}
|
||||||
|
|
||||||
|
model Customer {
|
||||||
|
id BigInt @id @default(autoincrement())
|
||||||
|
customerId String @unique @map("customer_id") @db.VarChar(256)
|
||||||
|
name String? @db.VarChar(128)
|
||||||
|
country String? @db.VarChar(64)
|
||||||
|
email String? @db.VarChar(128)
|
||||||
|
phone String? @db.VarChar(64)
|
||||||
|
company String? @db.VarChar(255)
|
||||||
|
registerTime DateTime? @map("register_time")
|
||||||
|
avatarUrl String? @map("avatar_url") @db.VarChar(512)
|
||||||
|
buyerTags String? @map("buyer_tags") @db.VarChar(255)
|
||||||
|
buyerFeatures String? @map("buyer_features") @db.VarChar(255)
|
||||||
|
bProductViews Int? @map("behavior_product_views")
|
||||||
|
bValidInquiries Int? @map("behavior_valid_inquiries")
|
||||||
|
bValidRfq Int? @map("behavior_valid_rfq")
|
||||||
|
bLoginDays Int? @map("behavior_login_days")
|
||||||
|
bSpamInquiries Int? @map("behavior_spam_inquiries")
|
||||||
|
bBlacklist Int? @map("behavior_blacklist")
|
||||||
|
bTopIndustry String? @map("behavior_top_industry") @db.VarChar(255)
|
||||||
|
recentProductsImages String? @map("recent_products_images") @db.Text
|
||||||
|
storeInquiryProduct String? @map("store_inquiry_product") @db.VarChar(512)
|
||||||
|
storeInquiryQty String? @map("store_inquiry_qty") @db.VarChar(32)
|
||||||
|
storeVisitProducts String? @map("store_visit_products") @db.Text
|
||||||
|
landline String? @map("landline") @db.VarChar(32)
|
||||||
|
businessType String? @map("business_type") @db.VarChar(128)
|
||||||
|
salesPlatform String? @map("sales_platform") @db.VarChar(255)
|
||||||
|
website String? @map("website") @db.VarChar(512)
|
||||||
|
inquiries Inquiry[]
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
|
@@index([country])
|
||||||
|
@@map("customers")
|
||||||
|
}
|
||||||
|
|
||||||
|
model Inquiry {
|
||||||
|
id BigInt @id @default(autoincrement())
|
||||||
|
inquiryId String @unique @map("inquiry_id") @db.VarChar(32)
|
||||||
|
customerId String? @map("customer_id") @db.VarChar(256)
|
||||||
|
manager String? @db.VarChar(128)
|
||||||
|
createTime DateTime @map("create_time")
|
||||||
|
updateTime DateTime @map("update_time")
|
||||||
|
status String? @db.VarChar(64)
|
||||||
|
msgPreview String? @map("msg_preview") @db.VarChar(512)
|
||||||
|
allInquiries String? @map("all_inquiries") @db.Text
|
||||||
|
isFlagged Boolean @default(false) @map("is_flagged")
|
||||||
|
messagesCount Int @default(0) @map("messages_count")
|
||||||
|
lastSendTime DateTime? @map("last_send_time")
|
||||||
|
hasFailedBatches Boolean @default(false) @map("has_failed_batches")
|
||||||
|
customer Customer? @relation(fields: [customerId], references: [customerId])
|
||||||
|
messages ChatMessage[]
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
|
@@index([customerId])
|
||||||
|
@@index([manager])
|
||||||
|
@@map("inquiries")
|
||||||
|
}
|
||||||
|
|
||||||
|
model ChatMessage {
|
||||||
|
id BigInt @id @default(autoincrement())
|
||||||
|
messageId String @unique @map("message_id") @db.VarChar(64)
|
||||||
|
inquiryId String @map("inquiry_id") @db.VarChar(32)
|
||||||
|
sender String? @db.VarChar(128)
|
||||||
|
direction Direction
|
||||||
|
sendTime DateTime @map("send_time")
|
||||||
|
content String @db.MediumText
|
||||||
|
translation String? @db.Text
|
||||||
|
inquiry Inquiry @relation(fields: [inquiryId], references: [inquiryId])
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
|
||||||
|
@@index([inquiryId, sendTime, messageId])
|
||||||
|
@@index([inquiryId])
|
||||||
|
@@map("chat_messages")
|
||||||
|
}
|
||||||
|
|
||||||
|
model User {
|
||||||
|
id BigInt @id @default(autoincrement())
|
||||||
|
username String @unique
|
||||||
|
passwordHash String @map("password_hash")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
|
||||||
|
@@map("users")
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# 一次性 mysqldump 备份(由 docker compose --profile backup run --rm backup 触发)
|
||||||
|
set -e
|
||||||
|
mkdir -p /backup
|
||||||
|
mysqldump -h mysql -uroot inquiry | gzip > "/backup/inquiry-$(date +%F).sql.gz"
|
||||||
|
# 14 天滚动清理
|
||||||
|
find /backup -name "inquiry-*.sql.gz" -mtime +14 -delete
|
||||||
|
echo "backup done: /backup/inquiry-$(date +%F).sql.gz"
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -e
|
||||||
|
BASE=http://localhost:8080
|
||||||
|
ITOK=dev-ingest-token-1234
|
||||||
|
|
||||||
|
JWT=$(curl -s -XPOST $BASE/api/auth/login -H 'Content-Type: application/json' \
|
||||||
|
-d '{"username":"admin","password":"admin-password-1234"}' \
|
||||||
|
| node -e "let s='';process.stdin.on('data',d=>s+=d).on('end',()=>console.log(JSON.parse(s).token))")
|
||||||
|
echo "auth ok"
|
||||||
|
|
||||||
|
curl -sf -XPOST $BASE/api/ingest -H "Authorization: Bearer $ITOK" -H 'Content-Type: application/json' \
|
||||||
|
-d '{"inquiry":{"inquiryId":"E2E-1","createTime":"2026-07-13T10:00:00Z","updateTime":"2026-07-13T10:00:00Z","manager":"tester","status":"new"},"customer":{"customerId":"c-e2e","name":"Test Buyer","country":"US"},"messages":[{"messageId":"e2e-m1","sendTime":"2026-07-13T10:00:00Z","direction":"outgoing","content":"hello e2e"}]}' >/dev/null
|
||||||
|
echo "ingest ok"
|
||||||
|
|
||||||
|
curl -sf "$BASE/api/inquiries/E2E-1" -H "Authorization: Bearer $JWT" | grep -q E2E-1 && echo "detail ok"
|
||||||
|
curl -sf "$BASE/api/inquiries/E2E-1/messages" -H "Authorization: Bearer $JWT" | grep -q e2e-m1 && echo "messages ok"
|
||||||
|
curl -sf "$BASE/api/stats/overview" -H "Authorization: Bearer $JWT" | grep -q total && echo "stats ok"
|
||||||
|
curl -sf "$BASE/api/export" -H "Authorization: Bearer $JWT" | grep -q E2E-1 && echo "export ok"
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import express from 'express';
|
||||||
|
import cors from 'cors';
|
||||||
|
import morgan from 'morgan';
|
||||||
|
import { errorHandler } from './middleware/error.js';
|
||||||
|
import { authRouter } from './routes/auth.js';
|
||||||
|
import { ingestRouter } from './routes/ingest.js';
|
||||||
|
import { inquiriesRouter } from './routes/inquiries.js';
|
||||||
|
import { statsRouter } from './routes/stats.js';
|
||||||
|
import { exportRouter } from './routes/export.js';
|
||||||
|
import { getPrisma } from './lib/prisma.js';
|
||||||
|
|
||||||
|
export function createApp() {
|
||||||
|
const app = express();
|
||||||
|
// 反代(OpenResty)一层:让 express-rate-limit / req.ip 取真实客户端 IP,
|
||||||
|
// 否则 X-Forwarded-For 存在但 trust proxy 关着,rate-limit 报 ERR_ERL_UNEXPECTED_X_FORWARDED_FOR
|
||||||
|
app.set('trust proxy', 1);
|
||||||
|
// Prisma 的 BigInt 主键默认无法 JSON 序列化,统一转字符串
|
||||||
|
app.set('json replacer', (_k: string, v: unknown) => (typeof v === 'bigint' ? v.toString() : v));
|
||||||
|
// MVP:CORS 全开(ingest 靠 token 鉴权;后台 Plan 3 联调时收窄到前端域名)。
|
||||||
|
// ALLOWED_ORIGINS 已在 env 就位(spec §10),Plan 3 联调时切换到动态 origin 校验函数:
|
||||||
|
// origin: (origin, cb) => {
|
||||||
|
// if (env.ALLOWED_ORIGINS === '*') return cb(null, true);
|
||||||
|
// const list = env.ALLOWED_ORIGINS.split(',').map(s => s.trim());
|
||||||
|
// if (!origin || list.includes(origin)) return cb(null, true);
|
||||||
|
// return cb(new Error('Not allowed by CORS'));
|
||||||
|
// }
|
||||||
|
app.use(cors({ origin: true }));
|
||||||
|
app.use(express.json({ limit: '50mb' }));
|
||||||
|
if (process.env.NODE_ENV !== 'test') app.use(morgan('dev'));
|
||||||
|
// health 探活:连得上 DB 才返回 200,否则 503(让 orchestrator 重启)
|
||||||
|
app.get('/api/health', async (_req, res) => {
|
||||||
|
try {
|
||||||
|
await getPrisma().$queryRaw`SELECT 1`;
|
||||||
|
res.json({ ok: true });
|
||||||
|
} catch {
|
||||||
|
res.status(503).json({ ok: false });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
app.use('/api/auth', authRouter);
|
||||||
|
app.use('/api/ingest', ingestRouter);
|
||||||
|
app.use('/api/inquiries', inquiriesRouter);
|
||||||
|
app.use('/api/stats', statsRouter);
|
||||||
|
app.use('/api/export', exportRouter);
|
||||||
|
app.use(errorHandler);
|
||||||
|
return app;
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
import { config as dotenvConfig } from 'dotenv';
|
||||||
|
dotenvConfig(); // 仅设置 process.env,不校验
|
||||||
|
|
||||||
|
const schema = z.object({
|
||||||
|
DATABASE_URL: z.string().regex(/^mysql:\/\//, 'must start with mysql://'),
|
||||||
|
INGEST_TOKEN: z.string().min(8),
|
||||||
|
JWT_SECRET: z.string().min(8),
|
||||||
|
JWT_EXPIRES_IN: z.string().default('7d'),
|
||||||
|
ADMIN_USERNAME: z.string(),
|
||||||
|
ADMIN_PASSWORD: z.string().min(8),
|
||||||
|
PORT: z.coerce.number().default(8080),
|
||||||
|
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
|
||||||
|
// 逗号分隔域名,默认 `*` 允许全部;Plan 3 联调时配具体前端域名(见 spec §10)
|
||||||
|
ALLOWED_ORIGINS: z.string().default('*'),
|
||||||
|
});
|
||||||
|
|
||||||
|
let _env: z.infer<typeof schema> | null = null;
|
||||||
|
export function loadEnv() {
|
||||||
|
if (_env) return _env;
|
||||||
|
const parsed = schema.safeParse(process.env);
|
||||||
|
if (!parsed.success) {
|
||||||
|
console.error('❌ invalid env:', parsed.error.flatten().fieldErrors);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
_env = parsed.data;
|
||||||
|
return _env;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const env = new Proxy({} as z.infer<typeof schema>, {
|
||||||
|
get: (_t, k) => (loadEnv() as any)[k],
|
||||||
|
});
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
|
||||||
|
let _prisma: PrismaClient | null = null;
|
||||||
|
|
||||||
|
export function getPrisma(): PrismaClient {
|
||||||
|
if (!_prisma) _prisma = new PrismaClient();
|
||||||
|
return _prisma;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function disconnectPrisma() {
|
||||||
|
if (_prisma) {
|
||||||
|
await _prisma.$disconnect();
|
||||||
|
_prisma = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import type { ErrorRequestHandler } from 'express';
|
||||||
|
import { env } from '../config/env.js';
|
||||||
|
|
||||||
|
export const errorHandler: ErrorRequestHandler = (err, _req, res, _next) => {
|
||||||
|
console.error(err);
|
||||||
|
const status = err.status ?? 500;
|
||||||
|
const isClientError = status >= 400 && status < 500;
|
||||||
|
// 5xx 在生产环境不回显内部 message(避免泄漏表名/SQL/堆栈);4xx 与 dev 环境保留
|
||||||
|
const message =
|
||||||
|
!isClientError && env.NODE_ENV === 'production'
|
||||||
|
? 'internal error'
|
||||||
|
: err.message ?? 'internal error';
|
||||||
|
res.status(status).json({ error: message });
|
||||||
|
};
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import type { RequestHandler } from 'express';
|
||||||
|
import { timingSafeEqual } from 'node:crypto';
|
||||||
|
import { env } from '../config/env.js';
|
||||||
|
|
||||||
|
export const ingestAuth: RequestHandler = (req, res, next) => {
|
||||||
|
const h = req.headers.authorization || '';
|
||||||
|
const token = h.startsWith('Bearer ') ? h.slice(7) : '';
|
||||||
|
const want = env.INGEST_TOKEN;
|
||||||
|
const ok =
|
||||||
|
token.length === want.length &&
|
||||||
|
timingSafeEqual(Buffer.from(token), Buffer.from(want));
|
||||||
|
if (!ok) return res.status(401).json({ error: 'invalid ingest token' });
|
||||||
|
next();
|
||||||
|
};
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import type { RequestHandler } from 'express';
|
||||||
|
import jwt from 'jsonwebtoken';
|
||||||
|
import { env } from '../config/env.js';
|
||||||
|
|
||||||
|
export const jwtAuth: RequestHandler = (req, res, next) => {
|
||||||
|
const h = req.headers.authorization || '';
|
||||||
|
const token = h.startsWith('Bearer ') ? h.slice(7) : '';
|
||||||
|
try {
|
||||||
|
(req as any).user = jwt.verify(token, env.JWT_SECRET);
|
||||||
|
next();
|
||||||
|
} catch {
|
||||||
|
res.status(401).json({ error: 'unauthorized' });
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import rateLimit from 'express-rate-limit';
|
||||||
|
|
||||||
|
export const ingestLimiter = rateLimit({
|
||||||
|
windowMs: 60_000,
|
||||||
|
max: 60,
|
||||||
|
standardHeaders: true,
|
||||||
|
legacyHeaders: false,
|
||||||
|
});
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import jwt from 'jsonwebtoken';
|
||||||
|
import { login } from '../services/auth.service.js';
|
||||||
|
import { env } from '../config/env.js';
|
||||||
|
import { jwtAuth } from '../middleware/jwtAuth.js';
|
||||||
|
|
||||||
|
export const authRouter = Router();
|
||||||
|
|
||||||
|
const LoginBody = z.object({
|
||||||
|
username: z.string().min(1),
|
||||||
|
password: z.string().min(1),
|
||||||
|
});
|
||||||
|
|
||||||
|
authRouter.post('/login', async (req, res, next) => {
|
||||||
|
const parsed = LoginBody.safeParse(req.body);
|
||||||
|
if (!parsed.success) return res.status(400).json({ error: 'invalid body', issues: parsed.error.flatten() });
|
||||||
|
try {
|
||||||
|
res.json(await login(parsed.data.username, parsed.data.password));
|
||||||
|
} catch (e) {
|
||||||
|
next(e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
authRouter.post('/refresh', jwtAuth, (req, res) => {
|
||||||
|
const u = (req as any).user;
|
||||||
|
res.json({
|
||||||
|
token: jwt.sign({ sub: u.sub, u: u.u }, env.JWT_SECRET, {
|
||||||
|
expiresIn: env.JWT_EXPIRES_IN,
|
||||||
|
} as jwt.SignOptions),
|
||||||
|
expiresIn: env.JWT_EXPIRES_IN,
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import { getPrisma } from '../lib/prisma.js';
|
||||||
|
import { jwtAuth } from '../middleware/jwtAuth.js';
|
||||||
|
import { buildInquiryWhere } from '../services/inquiry.service.js';
|
||||||
|
import { csvEscape } from '../utils/csv.js';
|
||||||
|
|
||||||
|
export const exportRouter = Router();
|
||||||
|
|
||||||
|
exportRouter.get('/', jwtAuth, async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
res.setHeader('Content-Type', 'text/csv; charset=utf-8');
|
||||||
|
res.setHeader('Content-Disposition', 'attachment; filename="inquiries.csv"');
|
||||||
|
res.write(''); // UTF-8 BOM
|
||||||
|
res.write('询价单号,所有询盘号,业务员,买家,国家,公司,邮箱,电话,注册时间,买家标签,买家特征,状态,创建时间,产品浏览数,有效询盘,有效RFQ数,登录天数,垃圾询盘数,被加为黑名单数,最常采购行业,最近询盘产品图\n');
|
||||||
|
const rows = await getPrisma().inquiry.findMany({
|
||||||
|
where: buildInquiryWhere(req.query),
|
||||||
|
include: { customer: true },
|
||||||
|
orderBy: { createTime: 'desc' },
|
||||||
|
});
|
||||||
|
for (const r of rows) {
|
||||||
|
const c = r.customer;
|
||||||
|
// 所有询盘号:从 allInquiries(JSON)提取 inquiryId,分号分隔
|
||||||
|
let allIds = r.inquiryId;
|
||||||
|
if (r.allInquiries) {
|
||||||
|
try { allIds = (JSON.parse(r.allInquiries) as any[]).map((x) => x.inquiryId).join('; '); } catch {}
|
||||||
|
}
|
||||||
|
res.write(
|
||||||
|
[
|
||||||
|
r.inquiryId,
|
||||||
|
allIds,
|
||||||
|
r.manager,
|
||||||
|
c?.name,
|
||||||
|
c?.country,
|
||||||
|
c?.company,
|
||||||
|
c?.email,
|
||||||
|
c?.phone,
|
||||||
|
c?.registerTime ? c.registerTime.toISOString() : '',
|
||||||
|
c?.buyerTags,
|
||||||
|
c?.buyerFeatures,
|
||||||
|
r.status,
|
||||||
|
r.createTime.toISOString(),
|
||||||
|
c?.bProductViews,
|
||||||
|
c?.bValidInquiries,
|
||||||
|
c?.bValidRfq,
|
||||||
|
c?.bLoginDays,
|
||||||
|
c?.bSpamInquiries,
|
||||||
|
c?.bBlacklist,
|
||||||
|
c?.bTopIndustry,
|
||||||
|
c?.recentProductsImages,
|
||||||
|
]
|
||||||
|
.map(csvEscape)
|
||||||
|
.join(',') + '\n',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
res.end();
|
||||||
|
} catch (e) {
|
||||||
|
next(e);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { ingestAuth } from '../middleware/ingestAuth.js';
|
||||||
|
import { ingestLimiter } from '../middleware/rateLimit.js';
|
||||||
|
import { ingest } from '../services/ingest.service.js';
|
||||||
|
|
||||||
|
const MessageSchema = z.object({
|
||||||
|
messageId: z.string().min(1),
|
||||||
|
sendTime: z.string().datetime(),
|
||||||
|
sender: z.string().optional(),
|
||||||
|
direction: z.enum(['incoming', 'outgoing', 'system']),
|
||||||
|
content: z.string(),
|
||||||
|
translation: z.string().optional().default(''),
|
||||||
|
});
|
||||||
|
|
||||||
|
const BehaviorSchema = z
|
||||||
|
.object({
|
||||||
|
productViews: z.number().nullable().optional(),
|
||||||
|
validInquiries: z.number().nullable().optional(),
|
||||||
|
validRfq: z.number().nullable().optional(),
|
||||||
|
loginDays: z.number().nullable().optional(),
|
||||||
|
spamInquiries: z.number().nullable().optional(),
|
||||||
|
blacklistCount: z.number().nullable().optional(),
|
||||||
|
topIndustry: z.string().nullable().optional(),
|
||||||
|
})
|
||||||
|
.optional();
|
||||||
|
|
||||||
|
const PayloadSchema = z.object({
|
||||||
|
inquiry: z.object({
|
||||||
|
inquiryId: z.string().min(1),
|
||||||
|
manager: z.string().optional(),
|
||||||
|
createTime: z.string().datetime(),
|
||||||
|
updateTime: z.string().datetime(),
|
||||||
|
status: z.string().optional(),
|
||||||
|
msgPreview: z.string().optional(),
|
||||||
|
isFlagged: z.boolean().optional().default(false),
|
||||||
|
}),
|
||||||
|
customer: z
|
||||||
|
.object({
|
||||||
|
customerId: z.string().min(1),
|
||||||
|
name: z.string().optional(),
|
||||||
|
country: z.string().nullable().optional(),
|
||||||
|
email: z.string().nullable().optional(),
|
||||||
|
landline: z.string().nullable().optional(),
|
||||||
|
businessType: z.string().nullable().optional(),
|
||||||
|
salesPlatform: z.string().nullable().optional(),
|
||||||
|
website: z.string().nullable().optional(),
|
||||||
|
phone: z.string().nullable().optional(),
|
||||||
|
company: z.string().nullable().optional(),
|
||||||
|
registerTime: z.string().nullable().optional(), // 页面是 "2016-08-27"(日期非 ISO),放宽
|
||||||
|
avatarUrl: z.string().optional(),
|
||||||
|
buyerTags: z.string().optional(),
|
||||||
|
buyerFeatures: z.string().optional(),
|
||||||
|
recentInquiryProducts: z.array(z.string()).optional(),
|
||||||
|
storeInquiryProduct: z.string().nullable().optional(),
|
||||||
|
storeInquiryQty: z.string().nullable().optional(),
|
||||||
|
storeVisitProducts: z.array(z.object({ img: z.string().optional(), url: z.string().optional(), productId: z.string().optional() })).optional(),
|
||||||
|
behavior: BehaviorSchema,
|
||||||
|
})
|
||||||
|
.nullable(),
|
||||||
|
messages: z.array(MessageSchema).min(0), // 阶段1:允许空(先走通列表+客户+行为,聊天后续补充)
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ingestRouter = Router();
|
||||||
|
|
||||||
|
ingestRouter.post('/', ingestLimiter, ingestAuth, async (req, res, next) => {
|
||||||
|
const parsed = PayloadSchema.safeParse(req.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
// format() 显示完整 path(如 messages.0.sendTime),便于定位 null 字段
|
||||||
|
console.error('[ingest 400]', JSON.stringify(parsed.error.format()));
|
||||||
|
return res
|
||||||
|
.status(400)
|
||||||
|
.json({ error: 'invalid payload', issues: parsed.error.flatten() });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const d = parsed.data;
|
||||||
|
console.log(`[ingest in] inquiry=${d.inquiry?.inquiryId} customer=${d.customer?.customerId ?? '-'} messages=${d.messages?.length ?? 0}`);
|
||||||
|
const result = await ingest(d);
|
||||||
|
console.log(`[ingest ok] inquiry=${d.inquiry?.inquiryId} -> ${JSON.stringify(result)}`);
|
||||||
|
res.json(result);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[ingest err] inquiry=', parsed.data.inquiry?.inquiryId, e);
|
||||||
|
next(e);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { jwtAuth } from '../middleware/jwtAuth.js';
|
||||||
|
import { getPrisma } from '../lib/prisma.js';
|
||||||
|
import { listInquiries, getInquiryDetail, listMessages } from '../services/inquiry.service.js';
|
||||||
|
import { csvEscape } from '../utils/csv.js';
|
||||||
|
|
||||||
|
export const inquiriesRouter = Router();
|
||||||
|
|
||||||
|
// list 查询参数:size/page 非数字时退回默认;dateFrom/dateTo 用 ISO datetime 校验,非法 → 400
|
||||||
|
const ListQuery = z.object({
|
||||||
|
size: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.refine((v) => v == null || /^\d+$/.test(v), { message: 'size must be an integer' }),
|
||||||
|
page: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.refine((v) => v == null || /^\d+$/.test(v), { message: 'page must be an integer' }),
|
||||||
|
dateFrom: z.string().datetime().optional(),
|
||||||
|
dateTo: z.string().datetime().optional(),
|
||||||
|
country: z.string().optional(),
|
||||||
|
manager: z.string().optional(),
|
||||||
|
status: z.string().optional(),
|
||||||
|
q: z.string().optional(),
|
||||||
|
sort: z.enum(['update_time_desc', 'create_time_asc', 'create_time_desc', 'updated_desc']).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const CursorQuery = z
|
||||||
|
.object({
|
||||||
|
afterSendTime: z.string().datetime().optional(),
|
||||||
|
afterMessageId: z.string().optional(),
|
||||||
|
beforeSendTime: z.string().datetime().optional(),
|
||||||
|
beforeMessageId: z.string().optional(),
|
||||||
|
size: z.string().optional(),
|
||||||
|
})
|
||||||
|
.refine(
|
||||||
|
(d) =>
|
||||||
|
(!!d.afterSendTime && !!d.afterMessageId) ||
|
||||||
|
(!d.afterSendTime && !d.afterMessageId),
|
||||||
|
{ message: 'after cursor fields must be both present or both absent' },
|
||||||
|
)
|
||||||
|
.refine(
|
||||||
|
(d) =>
|
||||||
|
(!!d.beforeSendTime && !!d.beforeMessageId) ||
|
||||||
|
(!d.beforeSendTime && !d.beforeMessageId),
|
||||||
|
{ message: 'before cursor fields must be both present or both absent' },
|
||||||
|
)
|
||||||
|
.refine(
|
||||||
|
(d) =>
|
||||||
|
!(
|
||||||
|
(d.afterSendTime || d.afterMessageId) &&
|
||||||
|
(d.beforeSendTime || d.beforeMessageId)
|
||||||
|
),
|
||||||
|
{ message: 'after and before cursors are mutually exclusive' },
|
||||||
|
);
|
||||||
|
|
||||||
|
inquiriesRouter.get('/', jwtAuth, async (req, res, next) => {
|
||||||
|
const parsed = ListQuery.safeParse(req.query);
|
||||||
|
if (!parsed.success) return res.status(400).json({ error: 'invalid query', issues: parsed.error.flatten() });
|
||||||
|
try {
|
||||||
|
res.json(await listInquiries(parsed.data));
|
||||||
|
} catch (e) {
|
||||||
|
next(e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
inquiriesRouter.get('/:id', jwtAuth, async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const r = await getInquiryDetail(req.params.id);
|
||||||
|
if (!r) return res.status(404).json({ error: 'not found' });
|
||||||
|
res.json(r);
|
||||||
|
} catch (e) {
|
||||||
|
next(e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
inquiriesRouter.get('/:id/messages', jwtAuth, async (req, res, next) => {
|
||||||
|
const parsed = CursorQuery.safeParse(req.query);
|
||||||
|
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
|
||||||
|
try {
|
||||||
|
res.json(await listMessages(req.params.id, parsed.data));
|
||||||
|
} catch (e) {
|
||||||
|
next(e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
inquiriesRouter.get('/:id/messages/export', jwtAuth, async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
res.setHeader('Content-Type', 'text/csv; charset=utf-8');
|
||||||
|
res.setHeader('Content-Disposition', `attachment; filename="chat_${req.params.id}.csv"`);
|
||||||
|
res.write(''); // UTF-8 BOM
|
||||||
|
res.write('时间,方向,发送者,原文,翻译\n');
|
||||||
|
const msgs = await getPrisma().chatMessage.findMany({
|
||||||
|
where: { inquiryId: req.params.id },
|
||||||
|
orderBy: [{ sendTime: 'asc' }, { messageId: 'asc' }],
|
||||||
|
});
|
||||||
|
for (const m of msgs) {
|
||||||
|
res.write(
|
||||||
|
[m.sendTime.toISOString(), m.direction, m.sender, m.content, m.translation ?? '']
|
||||||
|
.map(csvEscape)
|
||||||
|
.join(',') + '\n',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
res.end();
|
||||||
|
} catch (e) {
|
||||||
|
next(e);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import { jwtAuth } from '../middleware/jwtAuth.js';
|
||||||
|
import { overview } from '../services/stats.service.js';
|
||||||
|
|
||||||
|
export const statsRouter = Router();
|
||||||
|
|
||||||
|
statsRouter.get('/overview', jwtAuth, async (_req, res, next) => {
|
||||||
|
try {
|
||||||
|
res.json(await overview());
|
||||||
|
} catch (e) {
|
||||||
|
next(e);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { createApp } from './app.js';
|
||||||
|
import { loadEnv, env } from './config/env.js';
|
||||||
|
import { disconnectPrisma } from './lib/prisma.js';
|
||||||
|
import { bootstrapAdmin } from './services/auth.service.js';
|
||||||
|
|
||||||
|
async function shutdown(server: import('http').Server) {
|
||||||
|
await new Promise<void>((r) => server.close(() => r()));
|
||||||
|
await disconnectPrisma();
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
loadEnv();
|
||||||
|
await bootstrapAdmin();
|
||||||
|
const app = createApp();
|
||||||
|
const server = app.listen(env.PORT, () => console.log(`listening on ${env.PORT}`));
|
||||||
|
process.on('SIGTERM', () => shutdown(server));
|
||||||
|
process.on('SIGINT', () => shutdown(server));
|
||||||
|
process.on('unhandledRejection', (reason) => console.error('unhandledRejection', reason));
|
||||||
|
process.on('uncaughtException', (err) => {
|
||||||
|
console.error('uncaughtException', err);
|
||||||
|
shutdown(server);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import bcrypt from 'bcryptjs';
|
||||||
|
import jwt from 'jsonwebtoken';
|
||||||
|
import { getPrisma } from '../lib/prisma.js';
|
||||||
|
import { env } from '../config/env.js';
|
||||||
|
|
||||||
|
export async function bootstrapAdmin() {
|
||||||
|
const prisma = getPrisma();
|
||||||
|
if ((await prisma.user.count()) === 0) {
|
||||||
|
await prisma.user.create({
|
||||||
|
data: {
|
||||||
|
username: env.ADMIN_USERNAME,
|
||||||
|
passwordHash: await bcrypt.hash(env.ADMIN_PASSWORD, 10),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
console.log(`seeded admin "${env.ADMIN_USERNAME}"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function login(username: string, password: string) {
|
||||||
|
const prisma = getPrisma();
|
||||||
|
const user = await prisma.user.findUnique({ where: { username } });
|
||||||
|
if (!user || !(await bcrypt.compare(password, user.passwordHash)))
|
||||||
|
throw Object.assign(new Error('invalid credentials'), { status: 401 });
|
||||||
|
return {
|
||||||
|
token: jwt.sign({ sub: String(user.id), u: user.username }, env.JWT_SECRET, {
|
||||||
|
expiresIn: env.JWT_EXPIRES_IN,
|
||||||
|
} as jwt.SignOptions),
|
||||||
|
expiresIn: env.JWT_EXPIRES_IN,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import { getPrisma } from '../lib/prisma.js';
|
||||||
|
import { batchInsertMessages } from '../utils/batchInsert.js';
|
||||||
|
import { maxSendTime } from '../utils/time.js';
|
||||||
|
|
||||||
|
type Payload = {
|
||||||
|
inquiry: any;
|
||||||
|
customer: any | null;
|
||||||
|
messages: { messageId: string; sendTime: string; sender?: string; direction: string; content: string; translation?: string }[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function ingest(p: Payload) {
|
||||||
|
const prisma = getPrisma();
|
||||||
|
const { inquiry: iq, customer: cu, messages } = p;
|
||||||
|
const newLast = maxSendTime(messages);
|
||||||
|
|
||||||
|
// 按 customerId 去重:同客户只存 1 条代表 inquiry(同客户询盘详情/聊天一致)。
|
||||||
|
// 后续同客户询盘 → 追加到代表的 allInquiries,消息归代表,不新建 inquiry。
|
||||||
|
if (cu?.customerId) {
|
||||||
|
// 代表 = 该客户最早的询盘(createTime 升序第一);同客户后续询盘追加到 allInquiries
|
||||||
|
const rep = await prisma.inquiry.findFirst({
|
||||||
|
where: { customerId: cu.customerId },
|
||||||
|
orderBy: { createTime: 'asc' },
|
||||||
|
});
|
||||||
|
if (rep && rep.inquiryId !== iq.inquiryId) {
|
||||||
|
const allList: any[] = rep.allInquiries
|
||||||
|
? JSON.parse(rep.allInquiries)
|
||||||
|
: [{ inquiryId: rep.inquiryId, createTime: rep.createTime.toISOString(), status: rep.status, msgPreview: rep.msgPreview }];
|
||||||
|
if (!allList.find((x: any) => x.inquiryId === iq.inquiryId)) {
|
||||||
|
allList.push({ inquiryId: iq.inquiryId, createTime: iq.createTime, status: iq.status, msgPreview: iq.msgPreview });
|
||||||
|
}
|
||||||
|
// 代表 createTime = 该客户所有会话(询盘)里最早的创建时间
|
||||||
|
const allTimes = allList.map((x: any) => new Date(x.createTime).getTime()).filter((t: number) => !isNaN(t));
|
||||||
|
const earliest = allTimes.length ? new Date(Math.min(...allTimes)) : new Date(iq.createTime);
|
||||||
|
await prisma.$transaction(async (tx) => {
|
||||||
|
if (cu) {
|
||||||
|
const data = mapCustomer(cu);
|
||||||
|
await tx.customer.upsert({ where: { customerId: cu.customerId }, create: data, update: skipNulls(data) });
|
||||||
|
}
|
||||||
|
await tx.inquiry.update({
|
||||||
|
where: { inquiryId: rep.inquiryId },
|
||||||
|
data: {
|
||||||
|
allInquiries: JSON.stringify(allList),
|
||||||
|
createTime: earliest,
|
||||||
|
updateTime: new Date(iq.updateTime),
|
||||||
|
status: iq.status ?? undefined,
|
||||||
|
msgPreview: iq.msgPreview ?? undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
const { failedBatches } = await batchInsertMessages(rep.inquiryId, messages);
|
||||||
|
const total = await prisma.chatMessage.count({ where: { inquiryId: rep.inquiryId } });
|
||||||
|
await prisma.inquiry.update({
|
||||||
|
where: { inquiryId: rep.inquiryId },
|
||||||
|
data: { messagesCount: total, lastSendTime: newLast, hasFailedBatches: failedBatches > 0 },
|
||||||
|
});
|
||||||
|
return { ok: true, inserted: total, failedBatches, earlyReturn: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 新客户(或同 inquiryId 代表重抓):customer/inquiry 每次 upsert(保证行为/字段最新);
|
||||||
|
// 消息只在「有变化」时才 batchInsert(增量,skipDuplicates 去重),没变则跳过省一次批量插入。
|
||||||
|
const existing = await prisma.inquiry.findUnique({ where: { inquiryId: iq.inquiryId } });
|
||||||
|
const msgsChanged = !existing || existing.hasFailedBatches
|
||||||
|
|| existing.messagesCount !== messages.length
|
||||||
|
|| !(newLast && existing.lastSendTime && existing.lastSendTime.getTime() === newLast.getTime());
|
||||||
|
|
||||||
|
// 建/更新代表 inquiry + customer(每次,保证最新)
|
||||||
|
await prisma.$transaction(async (tx) => {
|
||||||
|
if (cu) {
|
||||||
|
const data = mapCustomer(cu);
|
||||||
|
await tx.customer.upsert({ where: { customerId: cu.customerId }, create: data, update: skipNulls(data) });
|
||||||
|
}
|
||||||
|
await tx.inquiry.upsert({
|
||||||
|
where: { inquiryId: iq.inquiryId },
|
||||||
|
create: {
|
||||||
|
inquiryId: iq.inquiryId, customerId: cu?.customerId ?? null, manager: iq.manager,
|
||||||
|
createTime: new Date(iq.createTime), updateTime: new Date(iq.updateTime),
|
||||||
|
status: iq.status, msgPreview: iq.msgPreview, isFlagged: iq.isFlagged,
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
customerId: cu?.customerId ?? null, manager: iq.manager,
|
||||||
|
createTime: new Date(iq.createTime), updateTime: new Date(iq.updateTime),
|
||||||
|
status: iq.status, msgPreview: iq.msgPreview, isFlagged: iq.isFlagged,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 消息没变 → 跳过 batchInsert(customer/inquiry 已更新 above);变了才插入 + 更新计数
|
||||||
|
if (!msgsChanged) {
|
||||||
|
return { ok: true, inserted: existing!.messagesCount, failedBatches: 0, earlyReturn: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
const { failedBatches } = await batchInsertMessages(iq.inquiryId, messages);
|
||||||
|
const total = await prisma.chatMessage.count({ where: { inquiryId: iq.inquiryId } });
|
||||||
|
await prisma.inquiry.update({
|
||||||
|
where: { inquiryId: iq.inquiryId },
|
||||||
|
data: { messagesCount: total, lastSendTime: newLast, hasFailedBatches: failedBatches > 0 },
|
||||||
|
});
|
||||||
|
return { ok: true, inserted: total, failedBatches, earlyReturn: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapCustomer(c: any) {
|
||||||
|
// 空串归一化为 null:避免空串占据 NOT NULL 语义、影响 stats 的 `country IS NOT NULL` 过滤等
|
||||||
|
const s = (v: any) => (v == null || v === '' ? null : v);
|
||||||
|
return {
|
||||||
|
customerId: c.customerId, name: s(c.name), country: s(c.country), email: s(c.email), phone: s(c.phone), landline: s(c.landline), businessType: s(c.businessType), salesPlatform: s(c.salesPlatform), website: s(c.website), company: s(c.company),
|
||||||
|
registerTime: c.registerTime ? new Date(c.registerTime) : null, avatarUrl: s(c.avatarUrl),
|
||||||
|
buyerTags: s(c.buyerTags), buyerFeatures: s(c.buyerFeatures),
|
||||||
|
bProductViews: c.behavior?.productViews ?? null, bValidInquiries: c.behavior?.validInquiries ?? null,
|
||||||
|
bValidRfq: c.behavior?.validRfq ?? null, bLoginDays: c.behavior?.loginDays ?? null,
|
||||||
|
bSpamInquiries: c.behavior?.spamInquiries ?? null, bBlacklist: c.behavior?.blacklistCount ?? null, bTopIndustry: c.behavior?.topIndustry ?? null, recentProductsImages: Array.isArray(c.recentInquiryProducts) && c.recentInquiryProducts.length ? JSON.stringify(c.recentInquiryProducts) : null, storeInquiryProduct: s(c.storeInquiryProduct), storeInquiryQty: s(c.storeInquiryQty), storeVisitProducts: Array.isArray(c.storeVisitProducts) && c.storeVisitProducts.length ? JSON.stringify(c.storeVisitProducts) : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// update 时跳过 null 字段(避免覆盖已落库的有效值,如上次抓到 behavior 这次没抓到)
|
||||||
|
function skipNulls(data: Record<string, any>) {
|
||||||
|
const out: Record<string, any> = {};
|
||||||
|
for (const [k, v] of Object.entries(data)) if (v != null) out[k] = v;
|
||||||
|
return out;
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import { Prisma } from '@prisma/client';
|
||||||
|
import { getPrisma } from '../lib/prisma.js';
|
||||||
|
|
||||||
|
export function buildInquiryWhere(q: any): Prisma.InquiryWhereInput {
|
||||||
|
const where: Prisma.InquiryWhereInput = {};
|
||||||
|
if (q.country) where.customer = { is: { country: { equals: q.country } } };
|
||||||
|
if (q.manager) where.manager = { equals: q.manager };
|
||||||
|
if (q.status) where.status = { equals: q.status };
|
||||||
|
if (q.dateFrom || q.dateTo) {
|
||||||
|
where.createTime = {};
|
||||||
|
if (q.dateFrom) (where.createTime as any).gte = new Date(q.dateFrom);
|
||||||
|
if (q.dateTo) (where.createTime as any).lte = new Date(q.dateTo);
|
||||||
|
}
|
||||||
|
if (q.q) where.OR = [
|
||||||
|
{ inquiryId: { contains: q.q } },
|
||||||
|
{ msgPreview: { contains: q.q } },
|
||||||
|
{ customer: { is: { name: { contains: q.q } } } },
|
||||||
|
{ customer: { is: { company: { contains: q.q } } } },
|
||||||
|
];
|
||||||
|
return where;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getInquiryDetail(inquiryId: string) {
|
||||||
|
return getPrisma().inquiry.findUnique({
|
||||||
|
where: { inquiryId },
|
||||||
|
include: { customer: true, _count: { select: { messages: true } } },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listInquiries(q: any) {
|
||||||
|
const prisma = getPrisma();
|
||||||
|
const size = Math.min(Number(q.size) || 20, 100);
|
||||||
|
const page = Number(q.page) || 1;
|
||||||
|
const where = buildInquiryWhere(q);
|
||||||
|
const orderBy = q.sort === 'update_time_desc' ? { updateTime: 'desc' as const }
|
||||||
|
: q.sort === 'create_time_asc' ? { createTime: 'asc' as const }
|
||||||
|
: q.sort === 'updated_desc' ? { updatedAt: 'desc' as const } // 抓取时间(入库 updatedAt,每次推送刷新)倒序
|
||||||
|
: { createTime: 'desc' as const };
|
||||||
|
const [items, total] = await Promise.all([
|
||||||
|
prisma.inquiry.findMany({ where, orderBy, skip: (page - 1) * size, take: size, include: { customer: true } }),
|
||||||
|
prisma.inquiry.count({ where }),
|
||||||
|
]);
|
||||||
|
return { items, total, page, size };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 微信式分页:默认从最新开始,向前(after=更新)或向后(before=更旧)翻页。
|
||||||
|
*
|
||||||
|
* - 无 cursor:取最新 size 条(反转成 asc 返回,顶旧底新),prevCursor 指向已加载最旧的一条,
|
||||||
|
* 前端滚到底部显示最新、向上「加载更多」用 prevCursor 拉更旧的历史。
|
||||||
|
* - before cursor:取 before 之前(更旧)的 size 条,prevCursor 继续向上。
|
||||||
|
* - after cursor:取 after 之后(更新)的 size 条,nextCursor 继续向前(保留兼容)。
|
||||||
|
*
|
||||||
|
* 默认 + before 都按 desc 取再反转 asc,保证返回数组始终是旧→新顺序(前端渲染统一)。
|
||||||
|
*/
|
||||||
|
export async function listMessages(
|
||||||
|
inquiryId: string,
|
||||||
|
q: {
|
||||||
|
afterSendTime?: string; afterMessageId?: string;
|
||||||
|
beforeSendTime?: string; beforeMessageId?: string;
|
||||||
|
size?: string;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
const prisma = getPrisma();
|
||||||
|
const size = Math.min(Number(q.size) || 50, 200);
|
||||||
|
const where: Prisma.ChatMessageWhereInput = { inquiryId };
|
||||||
|
const hasBefore = !!(q.beforeSendTime && q.beforeMessageId);
|
||||||
|
const hasAfter = !!(q.afterSendTime && q.afterMessageId);
|
||||||
|
|
||||||
|
let orderBy: Prisma.ChatMessageFindManyArgs['orderBy'];
|
||||||
|
if (hasBefore) {
|
||||||
|
const before = new Date(q.beforeSendTime as string);
|
||||||
|
where.OR = [
|
||||||
|
{ sendTime: { lt: before } },
|
||||||
|
{ sendTime: before, messageId: { lt: q.beforeMessageId as string } },
|
||||||
|
];
|
||||||
|
orderBy = [{ sendTime: 'desc' }, { messageId: 'desc' }];
|
||||||
|
} else if (hasAfter) {
|
||||||
|
const after = new Date(q.afterSendTime as string);
|
||||||
|
where.OR = [
|
||||||
|
{ sendTime: { gt: after } },
|
||||||
|
{ sendTime: after, messageId: { gt: q.afterMessageId as string } },
|
||||||
|
];
|
||||||
|
orderBy = [{ sendTime: 'asc' }, { messageId: 'asc' }];
|
||||||
|
} else {
|
||||||
|
// 默认:从最新开始
|
||||||
|
orderBy = [{ sendTime: 'desc' }, { messageId: 'desc' }];
|
||||||
|
}
|
||||||
|
|
||||||
|
const raw = await prisma.chatMessage.findMany({ where, orderBy, take: size });
|
||||||
|
// 默认 + before 按 desc 取,反转成 asc(旧→新);after 已是 asc
|
||||||
|
const items = hasAfter ? raw : raw.slice().reverse();
|
||||||
|
const first = items[0];
|
||||||
|
const last = items[items.length - 1];
|
||||||
|
const fullPage = items.length === size;
|
||||||
|
|
||||||
|
return {
|
||||||
|
items,
|
||||||
|
// prevCursor:向上加载更旧(默认 + before 模式)
|
||||||
|
prevCursor:
|
||||||
|
!hasAfter && first && fullPage
|
||||||
|
? { beforeSendTime: first.sendTime.toISOString(), beforeMessageId: first.messageId }
|
||||||
|
: null,
|
||||||
|
// nextCursor:向前加载更新(仅 after 模式)
|
||||||
|
nextCursor:
|
||||||
|
hasAfter && last && fullPage
|
||||||
|
? { afterSendTime: last.sendTime.toISOString(), afterMessageId: last.messageId }
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { getPrisma } from '../lib/prisma.js';
|
||||||
|
|
||||||
|
export async function overview() {
|
||||||
|
const prisma = getPrisma();
|
||||||
|
const total = await prisma.inquiry.count();
|
||||||
|
|
||||||
|
// 国家统计按 inquiry 计数(join customer):一个客户多条询盘会被多次计入,
|
||||||
|
// 反映"哪个国家的询盘最多",比按 customer 计数更贴合业务语义。
|
||||||
|
const countryTop10 = (
|
||||||
|
await prisma.$queryRaw<{ country: string | null; count: bigint | number }[]>`
|
||||||
|
SELECT c.country AS country, COUNT(*) AS count
|
||||||
|
FROM inquiries i JOIN customers c ON i.customer_id = c.customer_id
|
||||||
|
WHERE c.country IS NOT NULL
|
||||||
|
GROUP BY c.country ORDER BY count DESC LIMIT 10`
|
||||||
|
).map((r) => ({ country: r.country, count: Number(r.count) }));
|
||||||
|
|
||||||
|
const byManager = (
|
||||||
|
await prisma.inquiry.groupBy({
|
||||||
|
by: ['manager'],
|
||||||
|
_count: { id: true },
|
||||||
|
orderBy: { _count: { id: 'desc' } },
|
||||||
|
where: { manager: { not: null } },
|
||||||
|
})
|
||||||
|
).map((r) => ({ manager: r.manager, count: r._count.id }));
|
||||||
|
|
||||||
|
const monthly = await prisma.$queryRaw<{ ym: string; c: bigint }[]>`
|
||||||
|
SELECT DATE_FORMAT(create_time, '%Y-%m') AS ym, COUNT(*) AS c
|
||||||
|
FROM inquiries GROUP BY ym ORDER BY ym DESC LIMIT 12`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
total,
|
||||||
|
countryTop10,
|
||||||
|
byManager,
|
||||||
|
monthly: monthly.map((r) => ({ ym: r.ym, count: Number(r.c) })),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { getPrisma } from '../lib/prisma.js';
|
||||||
|
import type { Direction } from '@prisma/client';
|
||||||
|
|
||||||
|
const BATCH = 500;
|
||||||
|
|
||||||
|
export async function batchInsertMessages(inquiryId: string, messages: any[]) {
|
||||||
|
const prisma = getPrisma();
|
||||||
|
let failedBatches = 0;
|
||||||
|
for (let i = 0; i < messages.length; i += BATCH) {
|
||||||
|
const batch = messages.slice(i, i + BATCH);
|
||||||
|
try {
|
||||||
|
await prisma.chatMessage.createMany({
|
||||||
|
skipDuplicates: true,
|
||||||
|
data: batch.map(m => ({
|
||||||
|
messageId: m.messageId, inquiryId, sender: m.sender ?? null,
|
||||||
|
direction: m.direction as Direction, sendTime: new Date(m.sendTime),
|
||||||
|
content: m.content, translation: m.translation ?? '',
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`batch at offset ${i} failed:`, e);
|
||||||
|
failedBatches++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { failedBatches };
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
// 共用 CSV 字段转义:list export 与 single-inquiry chat export 都用同一份逻辑。
|
||||||
|
// 含 `,` `"` `\n` 的字段用 `"..."` 包裹,内部 `"` 转义为 `""`;null/undefined → 空串。
|
||||||
|
export function csvEscape(v: any): string {
|
||||||
|
const s = v == null ? '' : String(v);
|
||||||
|
return /[",\n]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s;
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export const maxSendTime = (msgs: { sendTime: string }[]) =>
|
||||||
|
msgs.length ? new Date(Math.max(...msgs.map(m => Date.parse(m.sendTime)))) : null;
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||||
|
import request from 'supertest';
|
||||||
|
import { setupApp, teardown } from './setup.js';
|
||||||
|
|
||||||
|
let app: any;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
({ app } = await setupApp());
|
||||||
|
}, 90000);
|
||||||
|
afterAll(async () => {
|
||||||
|
await teardown();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('auth', () => {
|
||||||
|
it('login with admin env creds returns token', async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/auth/login')
|
||||||
|
.send({ username: 'admin', password: 'admin-password-1234' });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.token).toMatch(/^[A-Za-z0-9._-]+$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('wrong password 401', async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/auth/login')
|
||||||
|
.send({ username: 'admin', password: 'wrong' });
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||||
|
import request from 'supertest';
|
||||||
|
import { setupApp, resetDb, teardown } from './setup.js';
|
||||||
|
import { makePayload } from './fixtures.js';
|
||||||
|
|
||||||
|
let app: any;
|
||||||
|
beforeAll(async () => {
|
||||||
|
({ app } = await setupApp());
|
||||||
|
}, 90000);
|
||||||
|
afterAll(async () => {
|
||||||
|
await teardown();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function login() {
|
||||||
|
return (
|
||||||
|
await request(app)
|
||||||
|
.post('/api/auth/login')
|
||||||
|
.send({ username: 'admin', password: 'admin-password-1234' })
|
||||||
|
).body.token;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pushInquiry(p: any) {
|
||||||
|
await request(app)
|
||||||
|
.post('/api/ingest')
|
||||||
|
.set('Authorization', 'Bearer dev-ingest-token-1234')
|
||||||
|
.send(p);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('export', () => {
|
||||||
|
it('list CSV has header + row, no chat content', async () => {
|
||||||
|
await resetDb();
|
||||||
|
await pushInquiry(makePayload()); // inquiryId 13776617920, messages hello/hi
|
||||||
|
const token = await login();
|
||||||
|
const res = await request(app).get('/api/export').set('Authorization', 'Bearer ' + token);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.headers['content-type']).toContain('text/csv');
|
||||||
|
const text = res.text;
|
||||||
|
expect(text).toContain('询价单号'); // 表头
|
||||||
|
expect(text).toContain('13776617920'); // 数据行
|
||||||
|
expect(text).not.toContain('hello'); // 列表导出不含聊天原文
|
||||||
|
});
|
||||||
|
|
||||||
|
it('single-inquiry chat CSV contains message content', async () => {
|
||||||
|
await resetDb();
|
||||||
|
await pushInquiry(makePayload());
|
||||||
|
const token = await login();
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/api/inquiries/13776617920/messages/export')
|
||||||
|
.set('Authorization', 'Bearer ' + token);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.text).toContain('时间');
|
||||||
|
expect(res.text).toContain('hello'); // 含聊天原文
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
export function makePayload(o: any = {}) {
|
||||||
|
return {
|
||||||
|
inquiry: {
|
||||||
|
inquiryId: '13776617920',
|
||||||
|
manager: 'cara Hu',
|
||||||
|
createTime: '2026-03-13T10:11:50Z',
|
||||||
|
updateTime: '2026-07-13T10:11:50Z',
|
||||||
|
status: '洽谈中',
|
||||||
|
msgPreview: 'hi',
|
||||||
|
isFlagged: false,
|
||||||
|
...o.inquiry,
|
||||||
|
},
|
||||||
|
customer: {
|
||||||
|
customerId: 'cid-1',
|
||||||
|
name: 'Carlos',
|
||||||
|
country: 'PE',
|
||||||
|
email: null,
|
||||||
|
company: 'World Shop',
|
||||||
|
registerTime: '2025-06-23T00:00:00Z',
|
||||||
|
avatarUrl: '',
|
||||||
|
buyerTags: 'TM沟通',
|
||||||
|
buyerFeatures: '',
|
||||||
|
behavior: { productViews: null, validInquiries: null, validRfq: 39 },
|
||||||
|
...o.customer,
|
||||||
|
},
|
||||||
|
messages: o.messages ?? [
|
||||||
|
{
|
||||||
|
messageId: 'm1',
|
||||||
|
sendTime: '2026-07-13T02:11:50Z',
|
||||||
|
sender: 'cara Hu',
|
||||||
|
direction: 'outgoing',
|
||||||
|
content: 'hello',
|
||||||
|
translation: '',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
messageId: 'm2',
|
||||||
|
sendTime: '2026-07-13T02:12:00Z',
|
||||||
|
sender: 'Carlos',
|
||||||
|
direction: 'incoming',
|
||||||
|
content: 'hi',
|
||||||
|
translation: '',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import request from 'supertest';
|
||||||
|
import { createApp } from '../src/app.js';
|
||||||
|
|
||||||
|
describe('health', () => {
|
||||||
|
it('ok (200 if DB reachable, 503 if not — both acceptable, never 500)', async () => {
|
||||||
|
const res = await request(createApp()).get('/api/health');
|
||||||
|
// 单元环境无容器连不上 → 503;有容器 → 200。两者都算通过(只杜绝 500 崩)
|
||||||
|
expect([200, 503]).toContain(res.status);
|
||||||
|
expect(res.body).toHaveProperty('ok');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||||
|
import request from 'supertest';
|
||||||
|
import { setupApp, teardown, resetDb } from './setup.js';
|
||||||
|
import { makePayload } from './fixtures.js';
|
||||||
|
|
||||||
|
const TOKEN = 'Bearer dev-ingest-token-1234';
|
||||||
|
|
||||||
|
let app: any;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
({ app } = await setupApp());
|
||||||
|
}, 90000);
|
||||||
|
afterAll(async () => {
|
||||||
|
await teardown();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ingest auth/validation', () => {
|
||||||
|
it('401 without token', async () => {
|
||||||
|
const res = await request(app).post('/api/ingest').send(makePayload());
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('400 bad payload', async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/ingest')
|
||||||
|
.set('Authorization', 'Bearer dev-ingest-token-1234')
|
||||||
|
.send({ inquiry: {} });
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ingest basic insert', () => {
|
||||||
|
it('accepts valid payload, inserts 2 messages', async () => {
|
||||||
|
await resetDb();
|
||||||
|
const res = await request(app).post('/api/ingest')
|
||||||
|
.set('Authorization', 'Bearer dev-ingest-token-1234').send(makePayload());
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.inserted).toBe(2);
|
||||||
|
expect(res.body.earlyReturn).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ingest idempotency + increment', () => {
|
||||||
|
it('push N → push N again early-returns', async () => {
|
||||||
|
await resetDb();
|
||||||
|
const p = makePayload();
|
||||||
|
const r1 = await request(app).post('/api/ingest').set('Authorization', TOKEN).send(p);
|
||||||
|
expect(r1.status).toBe(200);
|
||||||
|
expect(r1.body.inserted).toBe(2);
|
||||||
|
expect(r1.body.earlyReturn).toBe(false);
|
||||||
|
const r2 = await request(app).post('/api/ingest').set('Authorization', TOKEN).send(p);
|
||||||
|
expect(r2.body.earlyReturn).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('push N → push N+1 → push N+1 again early-returns (messagesCount=total regression)', async () => {
|
||||||
|
await resetDb();
|
||||||
|
const base = makePayload();
|
||||||
|
await request(app).post('/api/ingest').set('Authorization', TOKEN).send(base);
|
||||||
|
const withOneMore = makePayload({
|
||||||
|
messages: [
|
||||||
|
...base.messages,
|
||||||
|
{ messageId: 'm3', sendTime: '2026-07-13T02:13:00Z', sender: 'x', direction: 'incoming', content: 'c3', translation: '' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const r3 = await request(app).post('/api/ingest').set('Authorization', TOKEN).send(withOneMore);
|
||||||
|
expect(r3.body.inserted).toBe(3); // 总量 3,不是 delta 1
|
||||||
|
expect(r3.body.earlyReturn).toBe(false);
|
||||||
|
// 再推同样 3 条 → 早返回(若 messagesCount 退化成 delta,这里 earlyReturn 会是 false)
|
||||||
|
const r4 = await request(app).post('/api/ingest').set('Authorization', TOKEN).send(withOneMore);
|
||||||
|
expect(r4.body.earlyReturn).toBe(true);
|
||||||
|
expect(r4.body.inserted).toBe(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||||
|
import request from 'supertest';
|
||||||
|
import { setupApp, resetDb, teardown } from './setup.js';
|
||||||
|
import { makePayload } from './fixtures.js';
|
||||||
|
import { getPrisma } from '../src/lib/prisma.js';
|
||||||
|
|
||||||
|
let app: any;
|
||||||
|
beforeAll(async () => {
|
||||||
|
({ app } = await setupApp());
|
||||||
|
}, 90000);
|
||||||
|
afterAll(async () => {
|
||||||
|
await teardown();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function login() {
|
||||||
|
const r = await request(app)
|
||||||
|
.post('/api/auth/login')
|
||||||
|
.send({ username: 'admin', password: 'admin-password-1234' });
|
||||||
|
return r.body.token;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pushInquiry(payload: any) {
|
||||||
|
await request(app)
|
||||||
|
.post('/api/ingest')
|
||||||
|
.set('Authorization', 'Bearer dev-ingest-token-1234')
|
||||||
|
.send(payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('inquiries list', () => {
|
||||||
|
it('lists filtered by country', async () => {
|
||||||
|
await resetDb();
|
||||||
|
await pushInquiry(makePayload({ customer: { country: 'PE' } }));
|
||||||
|
await pushInquiry(
|
||||||
|
makePayload({ inquiry: { inquiryId: 'X2' }, customer: { customerId: 'c2', country: 'US' } }),
|
||||||
|
);
|
||||||
|
const token = await login();
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/api/inquiries?country=PE')
|
||||||
|
.set('Authorization', 'Bearer ' + token);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.items.length).toBe(1);
|
||||||
|
expect(res.body.items[0].customer.country).toBe('PE');
|
||||||
|
expect(res.body).toHaveProperty('total');
|
||||||
|
expect(res.body).toHaveProperty('page');
|
||||||
|
expect(res.body).toHaveProperty('size');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('401 without jwt', async () => {
|
||||||
|
const res = await request(app).get('/api/inquiries');
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('inquiries detail', () => {
|
||||||
|
it('gets inquiry detail with customer + message count', async () => {
|
||||||
|
await resetDb();
|
||||||
|
await pushInquiry(makePayload()); // inquiryId='13776617920', 2 messages
|
||||||
|
const token = await login();
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/api/inquiries/13776617920')
|
||||||
|
.set('Authorization', 'Bearer ' + token);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.inquiryId).toBe('13776617920');
|
||||||
|
expect(res.body.customer).toBeTruthy();
|
||||||
|
expect(res.body._count.messages).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('404 for unknown inquiry', async () => {
|
||||||
|
const token = await login();
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/api/inquiries/NOPE')
|
||||||
|
.set('Authorization', 'Bearer ' + token);
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('inquiry messages pagination', () => {
|
||||||
|
// 造 50 条消息(sendTime 各不同),用于测默认从最新 + before 向上翻页
|
||||||
|
async function seed50() {
|
||||||
|
await resetDb();
|
||||||
|
const prisma = getPrisma();
|
||||||
|
await pushInquiry(makePayload()); // 建 inquiry + 2 条消息
|
||||||
|
const base = Date.parse('2026-07-13T02:12:00Z');
|
||||||
|
await prisma.chatMessage.createMany({
|
||||||
|
data: Array.from({ length: 48 }, (_, i) => ({
|
||||||
|
messageId: 'p' + i,
|
||||||
|
inquiryId: '13776617920',
|
||||||
|
sender: 'x',
|
||||||
|
direction: 'incoming' as const,
|
||||||
|
sendTime: new Date(base + (i + 1) * 1000),
|
||||||
|
content: 'c' + i,
|
||||||
|
translation: '',
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
it('默认无 cursor 返回最新一页(asc 旧→新)+ prevCursor 指向更旧', async () => {
|
||||||
|
await seed50();
|
||||||
|
const token = await login();
|
||||||
|
const r1 = await request(app)
|
||||||
|
.get('/api/inquiries/13776617920/messages?size=20')
|
||||||
|
.set('Authorization', 'Bearer ' + token);
|
||||||
|
expect(r1.status).toBe(200);
|
||||||
|
expect(r1.body.items.length).toBe(20);
|
||||||
|
expect(r1.body.prevCursor).toBeTruthy();
|
||||||
|
// 默认从最新:返回的 20 条应是全局最新的 20 条,且按 sendTime 升序(顶旧底新)
|
||||||
|
const ts = r1.body.items.map((m: any) => new Date(m.sendTime).getTime());
|
||||||
|
for (let i = 1; i < ts.length; i++) expect(ts[i]).toBeGreaterThanOrEqual(ts[i - 1]);
|
||||||
|
// 最新一条(全局最大 sendTime)应在 items 末尾
|
||||||
|
const prisma = getPrisma();
|
||||||
|
const maxRow = await prisma.chatMessage.findFirst({
|
||||||
|
where: { inquiryId: '13776617920' },
|
||||||
|
orderBy: { sendTime: 'desc' },
|
||||||
|
});
|
||||||
|
expect(ts[ts.length - 1]).toBe(maxRow!.sendTime.getTime());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('before cursor 向上加载更旧,与已加载不重叠', async () => {
|
||||||
|
await seed50();
|
||||||
|
const token = await login();
|
||||||
|
const r1 = await request(app)
|
||||||
|
.get('/api/inquiries/13776617920/messages?size=20')
|
||||||
|
.set('Authorization', 'Bearer ' + token);
|
||||||
|
const c = r1.body.prevCursor;
|
||||||
|
expect(c).toBeTruthy();
|
||||||
|
const r2 = await request(app)
|
||||||
|
.get(
|
||||||
|
`/api/inquiries/13776617920/messages?size=20&beforeSendTime=${encodeURIComponent(c.beforeSendTime)}&beforeMessageId=${c.beforeMessageId}`,
|
||||||
|
)
|
||||||
|
.set('Authorization', 'Bearer ' + token);
|
||||||
|
expect(r2.status).toBe(200);
|
||||||
|
expect(r2.body.items.length).toBe(20);
|
||||||
|
// 不重叠
|
||||||
|
const ids1 = new Set(r1.body.items.map((m: any) => m.messageId));
|
||||||
|
expect(r2.body.items.every((m: any) => !ids1.has(m.messageId))).toBe(true);
|
||||||
|
// r2 全部比 r1 第一条更旧
|
||||||
|
const r1First = new Date(r1.body.items[0].sendTime).getTime();
|
||||||
|
expect(r2.body.items.every((m: any) => new Date(m.sendTime).getTime() <= r1First)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('400 when only one cursor field provided (after or before)', async () => {
|
||||||
|
const token = await login();
|
||||||
|
const r1 = await request(app)
|
||||||
|
.get('/api/inquiries/13776617920/messages?afterSendTime=2026-07-13T02:12:00Z')
|
||||||
|
.set('Authorization', 'Bearer ' + token);
|
||||||
|
expect(r1.status).toBe(400);
|
||||||
|
const r2 = await request(app)
|
||||||
|
.get('/api/inquiries/13776617920/messages?beforeSendTime=2026-07-13T02:12:00Z')
|
||||||
|
.set('Authorization', 'Bearer ' + token);
|
||||||
|
expect(r2.status).toBe(400);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { MySqlContainer } from '@testcontainers/mysql';
|
||||||
|
import type { StartedTestContainer } from 'testcontainers';
|
||||||
|
import { execSync } from 'node:child_process';
|
||||||
|
import { getPrisma, disconnectPrisma } from '../src/lib/prisma.js';
|
||||||
|
import { createApp } from '../src/app.js';
|
||||||
|
import { bootstrapAdmin } from '../src/services/auth.service.js';
|
||||||
|
|
||||||
|
// 注:MySqlContainer 默认 user=root password=test
|
||||||
|
let container: StartedTestContainer | null = null;
|
||||||
|
|
||||||
|
export async function setupApp() {
|
||||||
|
if (!container) {
|
||||||
|
container = await new MySqlContainer('mysql:8').withDatabase('t').start();
|
||||||
|
process.env.DATABASE_URL = `mysql://root:test@${container.getHost()}:${container.getMappedPort(3306)}/t`;
|
||||||
|
process.env.INGEST_TOKEN = 'dev-ingest-token-1234';
|
||||||
|
process.env.JWT_SECRET = 'dev-jwt-secret-1234';
|
||||||
|
process.env.ADMIN_USERNAME = 'admin';
|
||||||
|
process.env.ADMIN_PASSWORD = 'admin-password-1234';
|
||||||
|
execSync('npx prisma migrate deploy', { stdio: 'inherit', env: process.env });
|
||||||
|
}
|
||||||
|
const prisma = getPrisma();
|
||||||
|
await bootstrapAdmin(); // 确保 admin 存在(auth 测试需要)
|
||||||
|
const app = createApp();
|
||||||
|
return { app, prisma };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resetDb() {
|
||||||
|
const prisma = getPrisma();
|
||||||
|
await prisma.$executeRawUnsafe('SET FOREIGN_KEY_CHECKS=0');
|
||||||
|
for (const t of ['chat_messages', 'inquiries', 'customers', 'users']) {
|
||||||
|
await prisma.$executeRawUnsafe(`DELETE FROM ${t}`);
|
||||||
|
}
|
||||||
|
await prisma.$executeRawUnsafe('SET FOREIGN_KEY_CHECKS=1');
|
||||||
|
await bootstrapAdmin(); // resetDb 清了 users,重新 seed admin
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function teardown() {
|
||||||
|
await disconnectPrisma();
|
||||||
|
await container?.stop();
|
||||||
|
container = null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||||
|
import request from 'supertest';
|
||||||
|
import { setupApp, resetDb, teardown } from './setup.js';
|
||||||
|
import { makePayload } from './fixtures.js';
|
||||||
|
|
||||||
|
let app: any;
|
||||||
|
beforeAll(async () => {
|
||||||
|
({ app } = await setupApp());
|
||||||
|
}, 90000);
|
||||||
|
afterAll(async () => {
|
||||||
|
await teardown();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function login() {
|
||||||
|
return (
|
||||||
|
await request(app)
|
||||||
|
.post('/api/auth/login')
|
||||||
|
.send({ username: 'admin', password: 'admin-password-1234' })
|
||||||
|
).body.token;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pushInquiry(p: any) {
|
||||||
|
await request(app)
|
||||||
|
.post('/api/ingest')
|
||||||
|
.set('Authorization', 'Bearer dev-ingest-token-1234')
|
||||||
|
.send(p);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('stats overview', () => {
|
||||||
|
it('returns total / countryTop10 / byManager / monthly', async () => {
|
||||||
|
await resetDb();
|
||||||
|
await pushInquiry(makePayload({ customer: { country: 'PE', name: 'A' } }));
|
||||||
|
await pushInquiry(
|
||||||
|
makePayload({
|
||||||
|
inquiry: { inquiryId: 'X2' },
|
||||||
|
customer: { customerId: 'c2', country: 'US', name: 'B' },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const token = await login();
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/api/stats/overview')
|
||||||
|
.set('Authorization', 'Bearer ' + token);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.total).toBe(2);
|
||||||
|
expect(Array.isArray(res.body.countryTop10)).toBe(true);
|
||||||
|
expect(res.body.countryTop10.some((c: any) => c.country === 'PE')).toBe(true);
|
||||||
|
expect(Array.isArray(res.body.byManager)).toBe(true);
|
||||||
|
expect(Array.isArray(res.body.monthly)).toBe(true);
|
||||||
|
// 确认 _count 已重命名为 count(前端友好)
|
||||||
|
expect(res.body.countryTop10[0]).toHaveProperty('count');
|
||||||
|
expect(res.body.countryTop10[0].count).not.toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('401 without jwt', async () => {
|
||||||
|
const res = await request(app).get('/api/stats/overview');
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "NodeNext",
|
||||||
|
"moduleResolution": "NodeNext",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "src",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"types": ["vitest/globals"]
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
globals: true,
|
||||||
|
testTimeout: 90_000,
|
||||||
|
hookTimeout: 90_000,
|
||||||
|
poolOptions: { forks: { singleFork: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
# Build context filter — keeps the image build lean and reproducible.
|
||||||
|
# Stage 1 only needs package*.json + src + config; everything else is dead weight
|
||||||
|
# or would bust the layer cache (e.g. local dist/node_modules).
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
.gitattributes
|
||||||
|
|
||||||
|
# Editor / OS
|
||||||
|
.vscode
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# Logs & caches
|
||||||
|
*.log
|
||||||
|
*.local
|
||||||
|
.eslintcache
|
||||||
|
.vite
|
||||||
|
*.tsbuildinfo
|
||||||
|
coverage
|
||||||
|
|
||||||
|
# Tooling configs not needed inside the build image
|
||||||
|
docker-compose*.yml
|
||||||
|
Dockerfile
|
||||||
|
.dockerignore
|
||||||
|
|
||||||
|
# Test artifacts (vitest configs/tests are not exercised by `npm run build`)
|
||||||
|
vitest.config.ts
|
||||||
|
__screenshots__
|
||||||
|
cypress
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
[*.{js,jsx,mjs,cjs,ts,tsx,mts,cts,vue,css,scss,sass,less,styl}]
|
||||||
|
charset = utf-8
|
||||||
|
indent_size = 2
|
||||||
|
indent_style = space
|
||||||
|
insert_final_newline = true
|
||||||
|
trim_trailing_whitespace = true
|
||||||
|
end_of_line = lf
|
||||||
|
max_line_length = 100
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
* text=auto eol=lf
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||||
|
"plugins": ["eslint", "typescript", "unicorn", "oxc", "vue"],
|
||||||
|
"env": {
|
||||||
|
"browser": true
|
||||||
|
},
|
||||||
|
"categories": {
|
||||||
|
"correctness": "error"
|
||||||
|
}
|
||||||
|
}
|
||||||
Vendored
+8
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"recommendations": [
|
||||||
|
"Vue.volar",
|
||||||
|
"dbaeumer.vscode-eslint",
|
||||||
|
"EditorConfig.EditorConfig",
|
||||||
|
"oxc.oxc-vscode"
|
||||||
|
]
|
||||||
|
}
|
||||||
Vendored
+11
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"explorer.fileNesting.enabled": true,
|
||||||
|
"explorer.fileNesting.patterns": {
|
||||||
|
"tsconfig.json": "tsconfig.*.json, env.d.ts, typed-router.d.ts",
|
||||||
|
"vite.config.*": "jsconfig*, vitest.config.*, cypress.config.*, playwright.config.*",
|
||||||
|
"package.json": "package-lock.json, pnpm*, .yarnrc*, yarn*, .eslint*, eslint*, .oxlint*, oxlint*, .oxfmt*, .prettier*, prettier*, .editorconfig"
|
||||||
|
},
|
||||||
|
"editor.codeActionsOnSave": {
|
||||||
|
"source.fixAll": "explicit"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
# ---- Stage 1: build the Vue3 SPA ----
|
||||||
|
# node:22-alpine satisfies package.json engines (^22.18.0 || >=24.12.0).
|
||||||
|
FROM node:22-alpine AS build
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install deps first for better layer caching. package-lock.json is committed,
|
||||||
|
# so `npm ci` gives reproducible installs.
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
# Copy sources and build -> /app/dist
|
||||||
|
COPY . .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# ---- Stage 2: serve the static dist with nginx ----
|
||||||
|
FROM nginx:stable-alpine
|
||||||
|
|
||||||
|
# Built SPA -> nginx html root
|
||||||
|
COPY --from=build /app/dist /usr/share/nginx/html
|
||||||
|
|
||||||
|
# Reverse-proxy + SPA fallback config -> overwrites nginx's stock default.conf
|
||||||
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
|
||||||
|
EXPOSE 80
|
||||||
|
|
||||||
|
# nginx:stable-alpine ships no default healthcheck. Probe `/` (the SPA shell),
|
||||||
|
# NOT /api/health — we want to detect nginx itself being wedged, not a sick
|
||||||
|
# backend (healthy-nginx + sick-backend must not be misreported as unhealthy).
|
||||||
|
# busybox wget is already in the alpine image.
|
||||||
|
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||||
|
CMD wget -q --spider http://localhost/ || exit 1
|
||||||
|
|
||||||
|
# nginx image already has CMD ["nginx", "-g", "daemon off;"]
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
# frontend
|
||||||
|
|
||||||
|
Vue3 + TS + Element Plus 管理后台,对接 alibb-server 后端(`/api`)。计划见 docs/superpowers/plans/2026-07-14-frontend.md。
|
||||||
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { globalIgnores } from 'eslint/config'
|
||||||
|
import { defineConfigWithVueTs, vueTsConfigs } from '@vue/eslint-config-typescript'
|
||||||
|
import pluginVue from 'eslint-plugin-vue'
|
||||||
|
import pluginOxlint from 'eslint-plugin-oxlint'
|
||||||
|
|
||||||
|
// To allow more languages other than `ts` in `.vue` files, uncomment the following lines:
|
||||||
|
// import { configureVueProject } from '@vue/eslint-config-typescript'
|
||||||
|
// configureVueProject({ scriptLangs: ['ts', 'tsx'] })
|
||||||
|
// More info at https://github.com/vuejs/eslint-config-typescript/#advanced-setup
|
||||||
|
|
||||||
|
export default defineConfigWithVueTs(
|
||||||
|
{
|
||||||
|
name: 'app/files-to-lint',
|
||||||
|
files: ['**/*.{vue,ts,mts,tsx}'],
|
||||||
|
},
|
||||||
|
|
||||||
|
globalIgnores(['**/dist/**', '**/dist-ssr/**', '**/coverage/**']),
|
||||||
|
|
||||||
|
...pluginVue.configs['flat/essential'],
|
||||||
|
vueTsConfigs.recommended,
|
||||||
|
|
||||||
|
...pluginOxlint.buildFromOxlintConfigFile('.oxlintrc.json'),
|
||||||
|
)
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<link rel="icon" href="/favicon.ico">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Alibb 管理后台</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
# nginx default.conf — Vue3 SPA (history mode) + /api reverse proxy to backend
|
||||||
|
# Mounted at /etc/nginx/conf.d/default.conf by the frontend Dockerfile.
|
||||||
|
# Spec §10: client_max_body_size 50m (ingest payloads can be large; Express limit is 50mb).
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
listen [::]:80;
|
||||||
|
server_name _;
|
||||||
|
|
||||||
|
# Spec §10 reliability: allow large ingest payloads up to 50m.
|
||||||
|
client_max_body_size 50m;
|
||||||
|
|
||||||
|
# Defense-in-depth: don't leak the nginx version in Server: headers / error pages.
|
||||||
|
server_tokens off;
|
||||||
|
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
# ---- gzip: text assets over the wire ----
|
||||||
|
gzip on;
|
||||||
|
gzip_vary on;
|
||||||
|
gzip_min_length 1024;
|
||||||
|
gzip_proxied any;
|
||||||
|
gzip_comp_level 5;
|
||||||
|
gzip_types
|
||||||
|
text/plain
|
||||||
|
text/css
|
||||||
|
text/xml
|
||||||
|
application/json
|
||||||
|
application/javascript
|
||||||
|
text/javascript
|
||||||
|
application/xml+rss
|
||||||
|
application/atom+xml
|
||||||
|
image/svg+xml
|
||||||
|
font/ttf
|
||||||
|
font/otf
|
||||||
|
font/woff
|
||||||
|
font/woff2;
|
||||||
|
|
||||||
|
# ---- API reverse proxy: /api/* -> http://backend:8080/api/* ----
|
||||||
|
# No trailing slash on proxy_pass => the /api prefix is preserved, so
|
||||||
|
# /api/health -> http://backend:8080/api/health.
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://backend:8080;
|
||||||
|
|
||||||
|
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;
|
||||||
|
# Clear Connection so upstream keep-alive isn't disabled by client headers.
|
||||||
|
proxy_set_header Connection "";
|
||||||
|
|
||||||
|
# Backend has some slow queries; give them room.
|
||||||
|
proxy_connect_timeout 10s;
|
||||||
|
proxy_send_timeout 60s;
|
||||||
|
proxy_read_timeout 60s;
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---- Hashed static assets: long immutable cache ----
|
||||||
|
location /assets/ {
|
||||||
|
expires 1y;
|
||||||
|
add_header Cache-Control "public, max-age=31536000, immutable";
|
||||||
|
access_log off;
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---- SPA history-mode fallback (CRITICAL) ----
|
||||||
|
# vue-router uses createWebHistory, so deep links like /inquiries or
|
||||||
|
# /inquiries/:id MUST fall back to index.html instead of 404.
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
# index.html itself should never be aggressively cached, so deploys take
|
||||||
|
# effect immediately (the asset files it references are content-hashed).
|
||||||
|
location = /index.html {
|
||||||
|
add_header Cache-Control "no-cache, no-store, must-revalidate";
|
||||||
|
expires off;
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+7808
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,56 @@
|
|||||||
|
{
|
||||||
|
"name": "frontend",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "run-p type-check \"build-only {@}\" --",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"build-only": "vite build",
|
||||||
|
"type-check": "vue-tsc --build",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest",
|
||||||
|
"lint": "run-s \"lint:*\"",
|
||||||
|
"lint:oxlint": "oxlint . --fix",
|
||||||
|
"lint:eslint": "eslint . --fix --cache"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@element-plus/icons-vue": "^2.3.2",
|
||||||
|
"axios": "^1.18.1",
|
||||||
|
"dayjs": "^1.11.21",
|
||||||
|
"echarts": "^6.1.0",
|
||||||
|
"element-plus": "^2.14.3",
|
||||||
|
"pinia": "^3.0.4",
|
||||||
|
"vue": "^3.5.38",
|
||||||
|
"vue-echarts": "^8.0.1",
|
||||||
|
"vue-router": "^5.1.0",
|
||||||
|
"vue-virtual-scroller": "^3.0.4"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tsconfig/node24": "^24.0.4",
|
||||||
|
"@types/node": "^24.13.2",
|
||||||
|
"@vitejs/plugin-vue": "^6.0.7",
|
||||||
|
"@vitest/coverage-v8": "^4.1.10",
|
||||||
|
"@vue/eslint-config-typescript": "^14.8.0",
|
||||||
|
"@vue/test-utils": "^2.4.11",
|
||||||
|
"@vue/tsconfig": "^0.9.1",
|
||||||
|
"eslint": "^10.5.0",
|
||||||
|
"eslint-plugin-oxlint": "~1.69.0",
|
||||||
|
"eslint-plugin-vue": "~10.9.2",
|
||||||
|
"jiti": "^2.7.0",
|
||||||
|
"jsdom": "^29.1.1",
|
||||||
|
"npm-run-all2": "^9.0.2",
|
||||||
|
"oxlint": "~1.69.0",
|
||||||
|
"sass": "^1.101.0",
|
||||||
|
"typescript": "~6.0.0",
|
||||||
|
"vite": "^8.0.16",
|
||||||
|
"vite-plugin-vue-devtools": "^8.1.2",
|
||||||
|
"vitest": "^4.1.10",
|
||||||
|
"vue-eslint-parser": "^10.4.1",
|
||||||
|
"vue-tsc": "^3.3.5"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^22.18.0 || >=24.12.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
@@ -0,0 +1,7 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { RouterView } from 'vue-router'
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<RouterView />
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import axios, { type AxiosError, type InternalAxiosRequestConfig } from 'axios'
|
||||||
|
|
||||||
|
// Mock the router BEFORE importing request.ts so the dynamic import('@/router')
|
||||||
|
// inside the 401 handler resolves to our spy. The factory returns the default
|
||||||
|
// export shape (`{ default: router }`) that a dynamic import would yield.
|
||||||
|
const pushSpy = vi.fn()
|
||||||
|
const fakeRouter = {
|
||||||
|
currentRoute: { value: { path: '/inquiries' } },
|
||||||
|
push: pushSpy,
|
||||||
|
}
|
||||||
|
vi.mock('@/router', () => ({ default: fakeRouter }))
|
||||||
|
|
||||||
|
import request, { type ApiError } from '@/api/request'
|
||||||
|
import { TOKEN_KEY } from '@/stores/auth'
|
||||||
|
|
||||||
|
// Grab the actual interceptor handlers axios registered. `handlers[0]` is the
|
||||||
|
// first (and, for our single-use setup, only) interceptor of each kind.
|
||||||
|
function requestFulfilled() {
|
||||||
|
return request.interceptors.request.handlers[0]!.fulfilled as (
|
||||||
|
c: InternalAxiosRequestConfig,
|
||||||
|
) => InternalAxiosRequestConfig | Promise<InternalAxiosRequestConfig>
|
||||||
|
}
|
||||||
|
function responseRejected() {
|
||||||
|
return request.interceptors.response.handlers[0]!.rejected as (
|
||||||
|
e: unknown,
|
||||||
|
) => Promise<never>
|
||||||
|
}
|
||||||
|
|
||||||
|
function baseConfig(): InternalAxiosRequestConfig {
|
||||||
|
// A minimal but believable config object; the interceptor only reads/writes
|
||||||
|
// headers, so we don't need a full axios config here.
|
||||||
|
return { headers: {} } as unknown as InternalAxiosRequestConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
function axiosError(status: number, data?: unknown): AxiosError {
|
||||||
|
return {
|
||||||
|
name: 'AxiosError',
|
||||||
|
message: status === 401 ? 'Request failed with status code 401' : 'err',
|
||||||
|
config: {} as InternalAxiosRequestConfig,
|
||||||
|
response: { status, data, config: {} as InternalAxiosRequestConfig, headers: {}, statusText: '' },
|
||||||
|
} as AxiosError
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('request interceptor', () => {
|
||||||
|
beforeEach(() => localStorage.clear())
|
||||||
|
afterEach(() => {
|
||||||
|
localStorage.clear()
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('attaches Bearer header when a token is in localStorage', async () => {
|
||||||
|
localStorage.setItem(TOKEN_KEY, 'tok-123')
|
||||||
|
const cfg = await requestFulfilled()(baseConfig())
|
||||||
|
expect(cfg.headers.Authorization).toBe('Bearer tok-123')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('omits Authorization header when no token is present', async () => {
|
||||||
|
const cfg = await requestFulfilled()(baseConfig())
|
||||||
|
expect(cfg.headers.Authorization).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('preserves existing request headers when attaching Authorization (M1 guard)', async () => {
|
||||||
|
// Regression guard for M1: the interceptor must assign onto the existing
|
||||||
|
// AxiosHeaders instance, not replace it. A pre-existing header must survive.
|
||||||
|
localStorage.setItem(TOKEN_KEY, 'tok-456')
|
||||||
|
const cfg = {
|
||||||
|
headers: { 'X-Requested-With': 'xhr' },
|
||||||
|
} as unknown as InternalAxiosRequestConfig
|
||||||
|
const out = await requestFulfilled()(cfg)
|
||||||
|
expect(out.headers.Authorization).toBe('Bearer tok-456')
|
||||||
|
expect(out.headers['X-Requested-With']).toBe('xhr')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('response interceptor (error path)', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.clear()
|
||||||
|
// Reset the shared fake router's path so tests are order-independent
|
||||||
|
// (the "/login" no-redirect test below mutates this value).
|
||||||
|
fakeRouter.currentRoute.value.path = '/inquiries'
|
||||||
|
})
|
||||||
|
afterEach(() => {
|
||||||
|
localStorage.clear()
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('on 401: clears the token and redirects to /login', async () => {
|
||||||
|
localStorage.setItem(TOKEN_KEY, 'stale-token')
|
||||||
|
fakeRouter.currentRoute.value.path = '/inquiries'
|
||||||
|
|
||||||
|
const promise = responseRejected()(axiosError(401, { error: 'invalid token' }))
|
||||||
|
|
||||||
|
await expect(promise).rejects.toMatchObject({ status: 401 })
|
||||||
|
expect(localStorage.getItem(TOKEN_KEY)).toBeNull()
|
||||||
|
// dynamic import of '@/router' is resolved on next tick; flush microtasks.
|
||||||
|
await vi.dynamicImportSettled()
|
||||||
|
expect(pushSpy).toHaveBeenCalledWith('/login')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does NOT redirect when already on /login (avoid loops)', async () => {
|
||||||
|
localStorage.setItem(TOKEN_KEY, 'stale-token')
|
||||||
|
fakeRouter.currentRoute.value.path = '/login'
|
||||||
|
|
||||||
|
await expect(responseRejected()(axiosError(401))).rejects.toMatchObject({
|
||||||
|
status: 401,
|
||||||
|
})
|
||||||
|
await vi.dynamicImportSettled()
|
||||||
|
expect(pushSpy).not.toHaveBeenCalled()
|
||||||
|
// Token is still cleared regardless.
|
||||||
|
expect(localStorage.getItem(TOKEN_KEY)).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('on non-401: rejects with a normalized ApiError and keeps the token', async () => {
|
||||||
|
localStorage.setItem(TOKEN_KEY, 'good-token')
|
||||||
|
fakeRouter.currentRoute.value.path = '/inquiries'
|
||||||
|
|
||||||
|
const err = await responseRejected()(axiosError(500, { error: 'boom' })).catch(
|
||||||
|
(e: ApiError) => e,
|
||||||
|
)
|
||||||
|
expect(err.status).toBe(500)
|
||||||
|
expect(err.message).toBe('boom')
|
||||||
|
expect(localStorage.getItem(TOKEN_KEY)).toBe('good-token')
|
||||||
|
await vi.dynamicImportSettled()
|
||||||
|
expect(pushSpy).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('falls back to a generic message when the server body is empty', async () => {
|
||||||
|
const err = await responseRejected()(axiosError(0)).catch((e: ApiError) => e)
|
||||||
|
expect(err.status).toBe(0)
|
||||||
|
expect(typeof err.message).toBe('string')
|
||||||
|
expect(err.message.length).toBeGreaterThan(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('redacts Authorization and all headers from the cause (I3)', async () => {
|
||||||
|
// Simulate a real failed request whose config carries the live Bearer
|
||||||
|
// token (the request interceptor attached it before the call failed).
|
||||||
|
// The cause we surface must NOT leak that token / header / config.
|
||||||
|
const leakyError = {
|
||||||
|
name: 'AxiosError',
|
||||||
|
message: 'Request failed with status code 500',
|
||||||
|
code: 'ERR_BAD_RESPONSE',
|
||||||
|
config: { headers: { Authorization: 'Bearer leak-me-secret' } },
|
||||||
|
response: {
|
||||||
|
status: 500,
|
||||||
|
data: { error: 'boom' },
|
||||||
|
headers: {},
|
||||||
|
statusText: '',
|
||||||
|
config: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
const err = await responseRejected()(leakyError).catch((e: ApiError) => e)
|
||||||
|
const causeStr = JSON.stringify(err.cause)
|
||||||
|
expect(causeStr).not.toContain('leak-me-secret')
|
||||||
|
expect(causeStr).not.toContain('Authorization')
|
||||||
|
expect(causeStr).not.toContain('headers')
|
||||||
|
expect(causeStr).not.toContain('config')
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('cancellation passthrough (I1 — silent-cancel contract)', () => {
|
||||||
|
// Regression guard for I1: when a request is aborted (AbortController or
|
||||||
|
// axios `CancelToken`), axios raises a `CanceledError`. The interceptor
|
||||||
|
// MUST pass it through UNWRAPPED — i.e. reject with the SAME error object,
|
||||||
|
// NOT a normalized ApiError — so view-level `axios.isCancel(e)` guards fire
|
||||||
|
// and the abort stays silent. Wrapping it would surface a spurious
|
||||||
|
// `ElMessage.error("canceled")` toast on every navigate-away-mid-fetch.
|
||||||
|
it('rejects a CanceledError AS-IS (identity, not wrapped into ApiError)', async () => {
|
||||||
|
const cancelErr = new axios.CanceledError('canceled')
|
||||||
|
const err = await responseRejected()(cancelErr).catch((e: unknown) => e)
|
||||||
|
// Identity check — the interceptor returned `Promise.reject(error)`
|
||||||
|
// without rewrapping, so the caught value IS the original CanceledError.
|
||||||
|
expect(err).toBe(cancelErr)
|
||||||
|
// And `isCancel` agrees (this is what every consumer's catch runs).
|
||||||
|
expect(axios.isCancel(err)).toBe(true)
|
||||||
|
// Not wrapped: ApiError carries numeric `status` + `message`; the raw
|
||||||
|
// CanceledError does not.
|
||||||
|
expect(err).not.toHaveProperty('status')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects a legacy `axios.Cancel(...)` AS-IS too', async () => {
|
||||||
|
// Older axios call sites used `axios.Cancel` (plain Error subclass);
|
||||||
|
// `isCancel` returns true for both shapes, so the passthrough must too.
|
||||||
|
const cancelErr = axios.Cancel
|
||||||
|
? new (axios.Cancel as new (m?: string) => Error)('canceled')
|
||||||
|
: new axios.CanceledError('canceled')
|
||||||
|
const err = await responseRejected()(cancelErr).catch((e: unknown) => e)
|
||||||
|
expect(err).toBe(cancelErr)
|
||||||
|
expect(axios.isCancel(err)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does NOT trigger the 401 path for a canceled request', async () => {
|
||||||
|
// A cancel is not a 401 — make sure the early `isCancel` return short-
|
||||||
|
// circuits before the 401 handling. We assert the token survives and the
|
||||||
|
// router is never pushed.
|
||||||
|
localStorage.setItem(TOKEN_KEY, 'live-token')
|
||||||
|
fakeRouter.currentRoute.value.path = '/inquiries'
|
||||||
|
const cancelErr = new axios.CanceledError('canceled')
|
||||||
|
await responseRejected()(cancelErr).catch(() => {})
|
||||||
|
await vi.dynamicImportSettled()
|
||||||
|
expect(localStorage.getItem(TOKEN_KEY)).toBe('live-token')
|
||||||
|
expect(pushSpy).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import request from '@/api/request'
|
||||||
|
|
||||||
|
/** Shape of a successful POST /api/auth/login response. */
|
||||||
|
export interface LoginResponse {
|
||||||
|
token: string
|
||||||
|
expiresIn: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/auth/login with username + password.
|
||||||
|
* Throws a normalized {@link ApiError} (from the response interceptor) on
|
||||||
|
* failure — e.g. status 401 with message "invalid credentials".
|
||||||
|
*/
|
||||||
|
export async function loginApi(
|
||||||
|
username: string,
|
||||||
|
password: string,
|
||||||
|
): Promise<LoginResponse> {
|
||||||
|
const { data } = await request.post<LoginResponse>('/auth/login', {
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
})
|
||||||
|
return data
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import request from '@/api/request'
|
||||||
|
import { parseFilenameFromContentDisposition } from '@/utils/download'
|
||||||
|
import type { ListInquiriesParams } from '@/api/inquiry'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CSV export client for `GET /api/export`.
|
||||||
|
*
|
||||||
|
* Backend contract (verified live):
|
||||||
|
* - JWT required (Bearer auto-attached by the shared request client).
|
||||||
|
* - Query params = the list filters (`country, manager, status, dateFrom,
|
||||||
|
* dateTo, q`). `page/size/sort` are accepted but ignored — the export stream
|
||||||
|
* is unpaginated. Callers should reuse {@link buildListParams} so the
|
||||||
|
* date→ISO conversion stays in one place and the exported CSV matches what's
|
||||||
|
* on screen.
|
||||||
|
* - Response: `Content-Type: text/csv; charset=utf-8` (UTF-8 BOM),
|
||||||
|
* `Content-Disposition: attachment; filename="inquiries.csv"`.
|
||||||
|
*
|
||||||
|
* BLOB GOTCHA: with `responseType: 'blob'`, a non-2xx response body comes back
|
||||||
|
* as a Blob (not parsed JSON), so the request interceptor's server-message
|
||||||
|
* extraction in `toApiError` won't find `{error,message}` — `ApiError.message`
|
||||||
|
* falls back to the generic axios string. Per Task 7 spec, we DON'T over-
|
||||||
|
* engineer blob-error parsing here; callers surface a generic 导出失败 toast on
|
||||||
|
* real errors. The 401 path still works (the interceptor keys off HTTP status,
|
||||||
|
* not body), and cancellation is still passed through unwrapped so
|
||||||
|
* `axios.isCancel(e)` works in the catch.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Result of a successful export: the CSV bytes + the server-suggested filename. */
|
||||||
|
export interface ExportResult {
|
||||||
|
blob: Blob
|
||||||
|
filename: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Download the inquiries CSV for the given filters.
|
||||||
|
*
|
||||||
|
* @param params Filter params — pass {@link buildListParams}(filters) so dates
|
||||||
|
* are full ISO and empty filters are omitted. Reuses the list's
|
||||||
|
* `ListInquiriesParams` type so there's a single source of truth for the
|
||||||
|
* filter shape; the backend accepts the same keys (page/size/sort are
|
||||||
|
* accepted but ignored on the export endpoint).
|
||||||
|
* @param signal Optional abort signal — abort on unmount/newer request so a
|
||||||
|
* stale download doesn't fire its success toast after the user navigates.
|
||||||
|
*/
|
||||||
|
export async function exportInquiriesCsv(
|
||||||
|
params: ListInquiriesParams,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<ExportResult> {
|
||||||
|
const res = await request.get<Blob>('/export', {
|
||||||
|
params,
|
||||||
|
responseType: 'blob',
|
||||||
|
signal,
|
||||||
|
})
|
||||||
|
// `noUncheckedIndexedAccess` makes header index access `string | undefined`;
|
||||||
|
// the helper already accepts undefined and returns null.
|
||||||
|
const filename =
|
||||||
|
parseFilenameFromContentDisposition(
|
||||||
|
res.headers['content-disposition'],
|
||||||
|
) ?? 'inquiries.csv'
|
||||||
|
return { blob: res.data, filename }
|
||||||
|
}
|
||||||
@@ -0,0 +1,224 @@
|
|||||||
|
import request from '@/api/request'
|
||||||
|
import { buildMessagesParams } from '@/utils/messages'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API types + client for the inquiries endpoint.
|
||||||
|
*
|
||||||
|
* The shape mirrors the backend contract (verified live):
|
||||||
|
* `GET /api/inquiries` returns `{ items, total, page, size }`. Query params are
|
||||||
|
* all optional; empty filters SHOULD be omitted by callers (see
|
||||||
|
* {@link buildListParams}) so axios doesn't serialize them as `undefined`.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Sort keys supported by `GET /api/inquiries?sort=...`. Backend enum. */
|
||||||
|
export type InquirySort =
|
||||||
|
| 'update_time_desc'
|
||||||
|
| 'create_time_asc'
|
||||||
|
| 'create_time_desc'
|
||||||
|
| 'updated_desc'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Buyer / customer record embedded on each inquiry. All buyer-feature fields
|
||||||
|
* are nullable — the backend only populates them when the scrape produced them.
|
||||||
|
*/
|
||||||
|
export interface Customer {
|
||||||
|
customerId: string
|
||||||
|
name: string | null
|
||||||
|
country: string | null
|
||||||
|
email: string | null
|
||||||
|
phone: string | null
|
||||||
|
landline: string | null
|
||||||
|
businessType: string | null
|
||||||
|
salesPlatform: string | null
|
||||||
|
website: string | null
|
||||||
|
company: string | null
|
||||||
|
registerTime: string | null
|
||||||
|
avatarUrl: string | null
|
||||||
|
// VarChar(255) on the backend — a short tag/features string, or null when
|
||||||
|
// the scrape didn't populate them. Tightened from `unknown` (Task 4) so the
|
||||||
|
// detail view can render them directly via {{ customer.buyerTags ?? '—' }}.
|
||||||
|
buyerTags: string | null
|
||||||
|
buyerFeatures: string | null
|
||||||
|
bProductViews: number | null
|
||||||
|
bValidInquiries: number | null
|
||||||
|
bValidRfq: number | null
|
||||||
|
bLoginDays: number | null
|
||||||
|
bSpamInquiries: number | null
|
||||||
|
bBlacklist: number | null
|
||||||
|
bTopIndustry: string | null
|
||||||
|
recentProductsImages: string | null
|
||||||
|
storeInquiryProduct: string | null
|
||||||
|
storeInquiryQty: string | null
|
||||||
|
storeVisitProducts: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Inquiry row as returned by the list/detail endpoints (camelCase JSON). */
|
||||||
|
export interface Inquiry {
|
||||||
|
id: string
|
||||||
|
inquiryId: string
|
||||||
|
customerId: string | null
|
||||||
|
manager: string | null
|
||||||
|
createTime: string
|
||||||
|
updateTime: string
|
||||||
|
updatedAt: string
|
||||||
|
status: string | null
|
||||||
|
msgPreview: string | null
|
||||||
|
allInquiries: string | null
|
||||||
|
isFlagged: boolean
|
||||||
|
messagesCount: number
|
||||||
|
lastSendTime: string | null
|
||||||
|
hasFailedBatches: boolean
|
||||||
|
customer: Customer | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Query params for `GET /api/inquiries`. All fields optional. */
|
||||||
|
export interface ListInquiriesParams {
|
||||||
|
page?: number
|
||||||
|
size?: number
|
||||||
|
/** Full ISO datetime (e.g. `2026-07-14T00:00:00.000Z`); bare dates 400. */
|
||||||
|
dateFrom?: string
|
||||||
|
dateTo?: string
|
||||||
|
country?: string
|
||||||
|
manager?: string
|
||||||
|
status?: string
|
||||||
|
/** Free-text search across inquiryId / msgPreview / customer fields. */
|
||||||
|
q?: string
|
||||||
|
sort?: InquirySort
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Response body of `GET /api/inquiries`. */
|
||||||
|
export interface ListInquiriesResponse {
|
||||||
|
items: Inquiry[]
|
||||||
|
total: number
|
||||||
|
page: number
|
||||||
|
size: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch a page of inquiries. Empty/undefined params are still allowed by the
|
||||||
|
* backend; callers should pre-clean them via {@link buildListParams} so that
|
||||||
|
* unset filters aren't sent at all.
|
||||||
|
*
|
||||||
|
* The optional `signal` is forwarded to axios so callers can cancel an
|
||||||
|
* in-flight request (race guard / unmount cleanup). A canceled request rejects
|
||||||
|
* with an axios `CanceledError`; detect via `axios.isCancel(e)` BEFORE any
|
||||||
|
* toast/state handling — cancellation is expected and must stay silent.
|
||||||
|
*
|
||||||
|
* Otherwise throws a normalized {@link ApiError} (from the response
|
||||||
|
* interceptor) on failure — e.g. status 400 with message `"invalid query"`
|
||||||
|
* when a bare `YYYY-MM-DD` is passed for dateFrom.
|
||||||
|
*/
|
||||||
|
export async function listInquiries(
|
||||||
|
params: ListInquiriesParams,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<ListInquiriesResponse> {
|
||||||
|
const { data } = await request.get<ListInquiriesResponse>('/inquiries', {
|
||||||
|
params,
|
||||||
|
signal,
|
||||||
|
})
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// Detail + messages (Task 5)
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detail response = the Inquiry with nested `customer` and a `_count` for the
|
||||||
|
* related messages. (The Inquiry base type already carries `customer: Customer
|
||||||
|
* | null` from the list endpoint; re-declaring it here is the same shape — it
|
||||||
|
* just documents that detail always includes it.) Verified live.
|
||||||
|
*/
|
||||||
|
export interface InquiryDetail extends Inquiry {
|
||||||
|
customer: Customer | null
|
||||||
|
_count: { messages: number }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One chat row. `direction` drives the bubble rendering:
|
||||||
|
* - `'incoming'` → buyer's message → LEFT bubble
|
||||||
|
* - `'outgoing'` → our reply → RIGHT bubble
|
||||||
|
* - `'system'` → system event → centered muted note (no bubble)
|
||||||
|
*
|
||||||
|
* `sendTime` is a full ISO datetime; `content` is MEDIUMTEXT buyer-entered
|
||||||
|
* text — render as text only (never v-html, XSS).
|
||||||
|
*/
|
||||||
|
export interface ChatMessage {
|
||||||
|
messageId: string
|
||||||
|
inquiryId: string
|
||||||
|
sender: string | null
|
||||||
|
direction: 'incoming' | 'outgoing' | 'system'
|
||||||
|
sendTime: string
|
||||||
|
content: string
|
||||||
|
translation: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Backward cursor for `GET /inquiries/:id/messages` — points at the OLDEST
|
||||||
|
* currently-loaded message; pass it to fetch the next-older page (向上加载历史).
|
||||||
|
* The backend enforces a BOTH-OR-NEITHER rule: `beforeSendTime` +
|
||||||
|
* `beforeMessageId` must be sent together or not at all (else 400). Build query
|
||||||
|
* params via {@link buildMessagesParams} (in `@/utils/messages`) so that
|
||||||
|
* invariant is impossible to violate at a call site.
|
||||||
|
*/
|
||||||
|
export interface BeforeCursor {
|
||||||
|
beforeSendTime: string
|
||||||
|
beforeMessageId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Response body of `GET /api/inquiries/:id/messages`. */
|
||||||
|
export interface MessagesResponse {
|
||||||
|
items: ChatMessage[]
|
||||||
|
/**
|
||||||
|
* Cursor to load the next-older page (向上加载更旧历史), or `null` when the
|
||||||
|
* oldest page has been reached (已到顶). The backend returns items ascending
|
||||||
|
* (旧→新, 顶旧底新); page 1 is the NEWEST batch and "加载更多" walks backward
|
||||||
|
* in time ( prepend older messages on top ).
|
||||||
|
*/
|
||||||
|
prevCursor: BeforeCursor | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch a single inquiry's detail (customer + behavior + message count).
|
||||||
|
* 404 `{error:"not found"}` if `id` (the inquiryId business key) doesn't exist
|
||||||
|
* — surfaces as a normalized {@link ApiError} with `status: 404`.
|
||||||
|
*/
|
||||||
|
export async function getInquiryDetail(
|
||||||
|
id: string,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<InquiryDetail> {
|
||||||
|
const { data } = await request.get<InquiryDetail>(`/inquiries/${encodeURIComponent(id)}`, {
|
||||||
|
signal,
|
||||||
|
})
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch one page of chat messages for an inquiry. Pass no cursor for page 1
|
||||||
|
* (newest `size` messages — 微信式默认显示最新); pass the previously-returned
|
||||||
|
* `prevCursor` to walk backward in time (load older history, prepended on top).
|
||||||
|
* The both-or-neither rule for the cursor fields is enforced HERE — via
|
||||||
|
* {@link buildMessagesParams} — so this function is the single chokepoint that
|
||||||
|
* turns `(cursor, size)` into a backend-compliant query. No call site can
|
||||||
|
* accidentally send a half-populated cursor.
|
||||||
|
*
|
||||||
|
* Returns `{ items, prevCursor }`. `prevCursor` is `null` when the oldest page
|
||||||
|
* has been reached (items.length < size).
|
||||||
|
*/
|
||||||
|
export async function listMessages(
|
||||||
|
id: string,
|
||||||
|
cursor?: BeforeCursor,
|
||||||
|
size?: number,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<MessagesResponse> {
|
||||||
|
// buildMessagesParams enforces the both-or-neither cursor invariant and the
|
||||||
|
// size-validity rule (drops 0/negative/NaN), so this stays one line. The
|
||||||
|
// returned object is structurally `Record<string, string|number>` — fine for
|
||||||
|
// axios `params` serialization.
|
||||||
|
const params = buildMessagesParams(cursor, size)
|
||||||
|
const { data } = await request.get<MessagesResponse>(
|
||||||
|
`/inquiries/${encodeURIComponent(id)}/messages`,
|
||||||
|
{ params, signal },
|
||||||
|
)
|
||||||
|
return data
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
import axios, {
|
||||||
|
type AxiosError,
|
||||||
|
type AxiosResponse,
|
||||||
|
type InternalAxiosRequestConfig,
|
||||||
|
} from 'axios'
|
||||||
|
import { TOKEN_KEY } from '@/stores/auth'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared axios instance for all `/api/*` calls.
|
||||||
|
*
|
||||||
|
* - baseURL `/api` matches the Vite dev proxy (see vite.config.ts) and the
|
||||||
|
* backend's own `/api` prefix, so the same code works in dev and prod.
|
||||||
|
* - The request interceptor attaches the JWT Bearer header when present.
|
||||||
|
* - The response interceptor normalizes errors and handles 401 globally
|
||||||
|
* (clear token + redirect to /login) so call sites only need `.catch`.
|
||||||
|
*/
|
||||||
|
const request = axios.create({
|
||||||
|
baseURL: '/api',
|
||||||
|
// Backend customer-field queries can be slow; give them breathing room.
|
||||||
|
timeout: 30_000,
|
||||||
|
})
|
||||||
|
|
||||||
|
function currentToken(): string | null {
|
||||||
|
return localStorage.getItem(TOKEN_KEY)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Request interceptor: attach Authorization header ----------------------
|
||||||
|
request.interceptors.request.use(
|
||||||
|
(config: InternalAxiosRequestConfig) => {
|
||||||
|
const token = currentToken()
|
||||||
|
if (token) {
|
||||||
|
// `config.headers` is always populated by axios as an AxiosHeaders
|
||||||
|
// instance; assign directly. (Defaulting it to {} would replace that
|
||||||
|
// instance and drop any pre-existing headers — see request.test.ts
|
||||||
|
// "preserves existing request headers" regression guard.)
|
||||||
|
config.headers.Authorization = `Bearer ${token}`
|
||||||
|
}
|
||||||
|
return config
|
||||||
|
},
|
||||||
|
(error) => Promise.reject(error),
|
||||||
|
)
|
||||||
|
|
||||||
|
// ---- Response interceptor: normalize errors + handle 401 -------------------
|
||||||
|
|
||||||
|
/** Normalized error shape surfaced to call sites for ElMessage display. */
|
||||||
|
export interface ApiError {
|
||||||
|
status: number
|
||||||
|
message: string
|
||||||
|
/**
|
||||||
|
* Redacted, structured copy of the original axios error. We deliberately do
|
||||||
|
* NOT forward the raw axios error: its `config.headers.Authorization` carries
|
||||||
|
* the live Bearer token, and a stray `console.error` / Sentry breadcrumb /
|
||||||
|
* screenshot would leak it. Only safe, non-secret fields are surfaced.
|
||||||
|
*/
|
||||||
|
cause?: {
|
||||||
|
name?: string
|
||||||
|
message?: string
|
||||||
|
code?: string
|
||||||
|
response?: { status: number; data?: unknown }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toApiError(error: unknown): ApiError {
|
||||||
|
const axiosErr = error as AxiosError<{ error?: string; message?: string }>
|
||||||
|
const status = axiosErr.response?.status ?? 0
|
||||||
|
const serverMsg =
|
||||||
|
axiosErr.response?.data?.error ?? axiosErr.response?.data?.message
|
||||||
|
const message =
|
||||||
|
serverMsg ??
|
||||||
|
axiosErr.message ??
|
||||||
|
(status ? `请求失败 (${status})` : '网络错误,请稍后重试')
|
||||||
|
// Redact headers / config entirely (see ApiError.cause docs).
|
||||||
|
const cause: ApiError['cause'] = {
|
||||||
|
name: axiosErr.name,
|
||||||
|
message: axiosErr.message,
|
||||||
|
code: axiosErr.code,
|
||||||
|
response: axiosErr.response
|
||||||
|
? { status: axiosErr.response.status, data: axiosErr.response.data }
|
||||||
|
: undefined,
|
||||||
|
}
|
||||||
|
return { status, message, cause }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lazy import of the router to avoid a circular dependency at module init:
|
||||||
|
* `request.ts` <- `auth.ts`(api) <- `Login.vue` <- `router/index.ts` <- `request.ts`.
|
||||||
|
* Importing the router eagerly would pull Login.vue (and thus request.ts)
|
||||||
|
* back into the graph before it finishes defining. Dynamic import defers it
|
||||||
|
* to the first 401, by which point everything is fully initialized.
|
||||||
|
*/
|
||||||
|
async function goToLogin() {
|
||||||
|
try {
|
||||||
|
const { default: router } = await import('@/router')
|
||||||
|
// Avoid redirect loops: never push /login onto itself.
|
||||||
|
if (router.currentRoute.value.path !== '/login') {
|
||||||
|
void router.push('/login')
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Router not available (e.g. SSR, or import failed) — fall back to a hard
|
||||||
|
// navigation so the user still ends up logged out.
|
||||||
|
if (window.location.pathname !== '/login') {
|
||||||
|
window.location.assign('/login')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
request.interceptors.response.use(
|
||||||
|
(response: AxiosResponse) => response,
|
||||||
|
async (error: unknown) => {
|
||||||
|
// Cancellation (AbortController.abort() → axios CanceledError) MUST pass
|
||||||
|
// through UNWRAPPED. The 4 view-level consumers (Stats / InquiryList /
|
||||||
|
// InquiryDetail / ChatList) all gate their toast on `axios.isCancel(e)` so
|
||||||
|
// they can silently absorb aborts from navigation / unmount / a newer
|
||||||
|
// fetch. If we wrapped cancels into an ApiError here, `isCancel(e)` in the
|
||||||
|
// catch would never fire and the user would see a spurious
|
||||||
|
// `ElMessage.error("canceled")` toast on every navigate-away-mid-fetch.
|
||||||
|
if (axios.isCancel(error)) return Promise.reject(error)
|
||||||
|
const apiError = toApiError(error)
|
||||||
|
if (apiError.status === 401) {
|
||||||
|
// Token invalid/expired — drop it and bounce to /login.
|
||||||
|
localStorage.removeItem(TOKEN_KEY)
|
||||||
|
await goToLogin()
|
||||||
|
}
|
||||||
|
return Promise.reject(apiError)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
export default request
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import request from '@/api/request'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API types + client for the stats endpoint (Task 6).
|
||||||
|
*
|
||||||
|
* The shape mirrors the backend contract for `GET /api/stats/overview`
|
||||||
|
* (verified live): an aggregate payload covering the total inquiry count, a
|
||||||
|
* Top-10 countries list, a per-manager tally, and a 12-month trend. Empty DB
|
||||||
|
* yields `total: 0` with all arrays `[]`; consumers (Stats.vue) handle that
|
||||||
|
* gracefully without special-casing the call.
|
||||||
|
*
|
||||||
|
* NOTE: `monthly` arrives in **DESC** order (newest `YYYY-MM` first, capped at
|
||||||
|
* 12). The line chart wants chronological ASC order — call
|
||||||
|
* {@link sortMonthlyAsc} (in `@/utils/stats`) before plotting.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** One `{ country, count }` row from `countryTop10`. */
|
||||||
|
export interface CountryStat {
|
||||||
|
country: string
|
||||||
|
count: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One `{ manager, count }` row from `byManager`. */
|
||||||
|
export interface ManagerStat {
|
||||||
|
manager: string
|
||||||
|
count: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One `{ ym, count }` row from `monthly` (`ym` is `YYYY-MM`). */
|
||||||
|
export interface MonthStat {
|
||||||
|
ym: string
|
||||||
|
count: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Response body of `GET /api/stats/overview`. */
|
||||||
|
export interface StatsOverview {
|
||||||
|
/** Total inquiry count across all time. */
|
||||||
|
total: number
|
||||||
|
/** Top 10 countries by inquiry count, DESC. May be `[]` on an empty DB. */
|
||||||
|
countryTop10: CountryStat[]
|
||||||
|
/** Inquiry count per manager (业务员), DESC. May be `[]`. */
|
||||||
|
byManager: ManagerStat[]
|
||||||
|
/**
|
||||||
|
* Last 12 months of inquiry counts. `ym` is `YYYY-MM`. The backend returns
|
||||||
|
* these **DESC** (newest first); sort ascending at the chart layer.
|
||||||
|
*/
|
||||||
|
monthly: MonthStat[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch the stats overview payload. JWT is attached by the shared request
|
||||||
|
* interceptor; 401 is handled globally (clear token + redirect to /login).
|
||||||
|
*
|
||||||
|
* The optional `signal` is forwarded to axios so Stats.vue can cancel the
|
||||||
|
* in-flight request on unmount. A canceled request rejects with an axios
|
||||||
|
* `CanceledError`; detect via `axios.isCancel(e)` BEFORE any toast/state
|
||||||
|
* handling — cancellation is expected and must stay silent.
|
||||||
|
*
|
||||||
|
* Otherwise throws a normalized {@link ApiError} (from the response
|
||||||
|
* interceptor) on failure.
|
||||||
|
*/
|
||||||
|
export async function getStatsOverview(
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<StatsOverview> {
|
||||||
|
const { data } = await request.get<StatsOverview>('/stats/overview', {
|
||||||
|
signal,
|
||||||
|
})
|
||||||
|
return data
|
||||||
|
}
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import type { Customer } from '@/api/inquiry'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 客户行为数据 card —— 完全照搬 alibaba 详情页行为区的布局。
|
||||||
|
*
|
||||||
|
* 两个分组:
|
||||||
|
* 1. 「近90天站内行为(数据为T+2)」:6 个数字指标(产品浏览数/有效询盘/有效RFQ数/
|
||||||
|
* 登录天数/垃圾询盘数/被加为黑名单数)+ 最常采购行业。
|
||||||
|
* 2. 「累计线上交易」:订单总数/订单总金额/交易供应商数。
|
||||||
|
*
|
||||||
|
* alibaba 对业务员隐藏的字段(最近搜索、最近询盘产品、全部交易数据)我们同样拿不到
|
||||||
|
* 真实值,照 alibaba 显示「客户隐藏」占位,保持视觉一致。
|
||||||
|
*/
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
customer: Customer | null
|
||||||
|
}>()
|
||||||
|
|
||||||
|
/** alibaba 对业务员隐藏的字段 —— 我们也拿不到,照搬显示占位 */
|
||||||
|
const HIDDEN = '客户隐藏'
|
||||||
|
|
||||||
|
/** 6 个数字行为指标(alibaba「近90天站内行为」区) */
|
||||||
|
const numericMetrics = computed<
|
||||||
|
Array<{ key: string; label: string; value: number | null }>
|
||||||
|
>(() => {
|
||||||
|
const c = props.customer
|
||||||
|
return [
|
||||||
|
{ key: 'views', label: '产品浏览数', value: c?.bProductViews ?? null },
|
||||||
|
{ key: 'inq', label: '有效询盘', value: c?.bValidInquiries ?? null },
|
||||||
|
{ key: 'rfq', label: '有效RFQ数', value: c?.bValidRfq ?? null },
|
||||||
|
{ key: 'login', label: '登录天数', value: c?.bLoginDays ?? null },
|
||||||
|
{ key: 'spam', label: '垃圾询盘数', value: c?.bSpamInquiries ?? null },
|
||||||
|
{ key: 'blacklist', label: '被加为黑名单数', value: c?.bBlacklist ?? null },
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 最常采购行业是字符串,单独一行 */
|
||||||
|
const topIndustry = computed(() => props.customer?.bTopIndustry ?? null)
|
||||||
|
|
||||||
|
/** 最近询盘产品(图片 URL 列表,后端存 JSON 字符串) */
|
||||||
|
const recentProducts = computed<string[]>(() => {
|
||||||
|
const raw = props.customer?.recentProductsImages
|
||||||
|
if (!raw) return []
|
||||||
|
try {
|
||||||
|
return JSON.parse(raw) as string[]
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 店内行为:询盘产品名 + 数量 + 浏览产品(图/链接) */
|
||||||
|
const storeInquiryProduct = computed(() => props.customer?.storeInquiryProduct ?? null)
|
||||||
|
const storeInquiryQty = computed(() => props.customer?.storeInquiryQty ?? null)
|
||||||
|
const storeVisitProducts = computed<Array<{ img?: string; url?: string; productId?: string }>>(() => {
|
||||||
|
const raw = props.customer?.storeVisitProducts
|
||||||
|
if (!raw) return []
|
||||||
|
try { return JSON.parse(raw) } catch { return [] }
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<el-card class="behavior-card" shadow="never">
|
||||||
|
<template #header>
|
||||||
|
<span class="card-title">客户行为数据</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- 店内行为(店铺内:询盘产品 + 浏览产品,部分询盘才有) -->
|
||||||
|
<div v-if="storeInquiryProduct || storeVisitProducts.length" class="section">
|
||||||
|
<div class="section-title">店内行为</div>
|
||||||
|
<div class="kv-list">
|
||||||
|
<div v-if="storeInquiryProduct" class="kv">
|
||||||
|
<span class="k">询盘产品</span>
|
||||||
|
<span class="v">{{ storeInquiryProduct }}<span v-if="storeInquiryQty"> × {{ storeInquiryQty }}</span></span>
|
||||||
|
</div>
|
||||||
|
<div v-if="storeVisitProducts.length" class="kv">
|
||||||
|
<span class="k">浏览产品</span>
|
||||||
|
<span class="v">
|
||||||
|
<a v-for="(p, i) in storeVisitProducts" :key="i" :href="p.url" target="_blank" rel="noopener" class="visit-link">
|
||||||
|
<img v-if="p.img" :src="p.img" :alt="`浏览产品${i + 1}`" class="prod-img" />
|
||||||
|
</a>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<el-divider v-if="storeInquiryProduct || storeVisitProducts.length" class="sep" />
|
||||||
|
|
||||||
|
<!-- 分组一:近90天站内行为 -->
|
||||||
|
<div class="section">
|
||||||
|
<div class="section-title">近90天站内行为<span class="hint">(数据为T+2)</span></div>
|
||||||
|
<div class="stat-grid">
|
||||||
|
<div v-for="m in numericMetrics" :key="m.key" class="stat">
|
||||||
|
<div class="stat-num">{{ m.value == null ? '—' : m.value }}</div>
|
||||||
|
<div class="stat-label">{{ m.label }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="kv-list">
|
||||||
|
<div class="kv"><span class="k">最近搜索</span><span class="v muted">{{ HIDDEN }}</span></div>
|
||||||
|
<div class="kv"><span class="k">最常采购行业</span><span class="v">{{ topIndustry || HIDDEN }}</span></div>
|
||||||
|
<div class="kv">
|
||||||
|
<span class="k">最近询盘产品</span>
|
||||||
|
<span class="v">
|
||||||
|
<img
|
||||||
|
v-for="(src, i) in recentProducts"
|
||||||
|
:key="i"
|
||||||
|
:src="src"
|
||||||
|
:alt="`产品${i + 1}`"
|
||||||
|
class="prod-img"
|
||||||
|
/>
|
||||||
|
<span v-if="!recentProducts.length" class="muted">{{ HIDDEN }}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-divider class="sep" />
|
||||||
|
|
||||||
|
<!-- 分组二:累计线上交易(alibaba 全部「客户隐藏」,拿不到真实值) -->
|
||||||
|
<div class="section">
|
||||||
|
<div class="section-title">累计线上交易</div>
|
||||||
|
<div class="kv-list">
|
||||||
|
<div class="kv"><span class="k">订单总数</span><span class="v muted">{{ HIDDEN }}</span></div>
|
||||||
|
<div class="kv"><span class="k">订单总金额($)</span><span class="v muted">{{ HIDDEN }}</span></div>
|
||||||
|
<div class="kv"><span class="k">交易供应商数</span><span class="v muted">{{ HIDDEN }}</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.behavior-card { height: 100%; }
|
||||||
|
.card-title { font-weight: 600; }
|
||||||
|
|
||||||
|
.section { margin-bottom: 4px; }
|
||||||
|
.section-title {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--el-text-color-primary);
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.section-title .hint {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 400;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
margin-left: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
.stat {
|
||||||
|
text-align: center;
|
||||||
|
padding: 12px 4px;
|
||||||
|
background: var(--el-fill-color-light);
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
.stat-num {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.2;
|
||||||
|
color: var(--el-color-primary);
|
||||||
|
}
|
||||||
|
.stat-label {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kv-list { display: flex; flex-direction: column; gap: 8px; }
|
||||||
|
.kv {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 13px;
|
||||||
|
padding: 4px 0;
|
||||||
|
}
|
||||||
|
.kv .k { color: var(--el-text-color-secondary); }
|
||||||
|
.kv .v { font-weight: 500; color: var(--el-text-color-primary); text-align: right; word-break: break-all; }
|
||||||
|
.kv .v.muted { color: var(--el-text-color-placeholder); font-weight: 400; }
|
||||||
|
|
||||||
|
.sep { margin: 16px 0; }
|
||||||
|
|
||||||
|
.prod-img {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid var(--el-border-color-lighter);
|
||||||
|
margin-left: 6px;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,508 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onBeforeUnmount, onMounted, nextTick, ref } from 'vue'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import axios from 'axios'
|
||||||
|
import { listMessages, type ChatMessage, type BeforeCursor } from '@/api/inquiry'
|
||||||
|
import { extractApiErrorMessage, formatTime } from '@/utils/format'
|
||||||
|
import { mergeMessages } from '@/utils/messages'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 微信式聊天列表(Task 5 重构)。
|
||||||
|
*
|
||||||
|
* 后端默认返回最新一页(asc 旧→新),本组件:
|
||||||
|
* - 打开 → 滚到底部显示最新
|
||||||
|
* - 顶部「加载更早的消息」用 prevCursor 向上翻更旧的历史,prepend 到顶部
|
||||||
|
* - prepend 后保持视觉滚动位置(跳到「原第一条」的位置,避免视角跳到顶部)
|
||||||
|
*
|
||||||
|
* DynamicScroller(vue-virtual-scroller v3)自动测量可变高度(聊天 content 是
|
||||||
|
* MEDIUMTEXT 可能很长)。`scrollToBottom`/`scrollToItem` 通过 ref 调用。
|
||||||
|
*
|
||||||
|
* Race / unmount safety:每次 fetch 用新 AbortController;新 fetch 或 unmount
|
||||||
|
* 中止进行中的。中止 rejection 被吞掉(不 toast、不清 loading)。
|
||||||
|
*/
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
inquiryId: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
/** 每页条数(匹配后端约定)。 */
|
||||||
|
const PAGE_SIZE = 50
|
||||||
|
|
||||||
|
const messages = ref<ChatMessage[]>([])
|
||||||
|
/**
|
||||||
|
* 向上加载更旧的游标(指向已加载最旧的一条);`null` 表示已到顶,隐藏「加载更早」。
|
||||||
|
*/
|
||||||
|
const prevCursor = ref<BeforeCursor | null>(null)
|
||||||
|
|
||||||
|
const loading = ref(false) // 首屏加载 spinner(v-loading)
|
||||||
|
const loadingMore = ref(false) // 向上加载 spinner(顶部按钮)
|
||||||
|
|
||||||
|
/** 已到顶(无更旧历史)。 */
|
||||||
|
const allLoaded = computed(() => prevCursor.value === null)
|
||||||
|
|
||||||
|
/** 当前进行中 fetch 的 AbortController。 */
|
||||||
|
let activeController: AbortController | null = null
|
||||||
|
|
||||||
|
/** DynamicScroller 实例 ref,用于 scrollToBottom / scrollToItem。 */
|
||||||
|
const scroller = ref<{ scrollToBottom?: () => void; scrollToItem?: (i: number) => void } | null>(null)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 拉一页并合并。首屏(无 cursor)加载后滚到底部;向上翻(prepend)后保持视角。
|
||||||
|
*
|
||||||
|
* @param cursor 向上游标;省略为首屏(最新一页)。
|
||||||
|
*/
|
||||||
|
async function fetchPage(cursor?: BeforeCursor) {
|
||||||
|
activeController?.abort()
|
||||||
|
const controller = new AbortController()
|
||||||
|
activeController = controller
|
||||||
|
|
||||||
|
const isFirst = !cursor
|
||||||
|
const prevCount = messages.value.length // prepend 后用来恢复 scroll 位置
|
||||||
|
if (isFirst) loading.value = true
|
||||||
|
else loadingMore.value = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await listMessages(
|
||||||
|
props.inquiryId,
|
||||||
|
cursor,
|
||||||
|
PAGE_SIZE,
|
||||||
|
controller.signal,
|
||||||
|
)
|
||||||
|
// mergeMessages dedup by messageId + sort asc,prepend 更旧后仍是旧→新顺序
|
||||||
|
messages.value = mergeMessages(messages.value, res.items)
|
||||||
|
prevCursor.value = res.prevCursor
|
||||||
|
|
||||||
|
await nextTick()
|
||||||
|
if (isFirst) {
|
||||||
|
// 首屏:滚到底部,显示最新一条
|
||||||
|
scroller.value?.scrollToBottom?.()
|
||||||
|
} else {
|
||||||
|
// 向上加载更旧(prepend):跳到「原第一条」的新位置 = 新增条数,保持视角
|
||||||
|
const added = messages.value.length - prevCount
|
||||||
|
if (added > 0) scroller.value?.scrollToItem?.(added)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// 中止(新 fetch / unmount)—— 预期,静默。必须在 toast/state 之前判断。
|
||||||
|
if (axios.isCancel(e)) return
|
||||||
|
const message = extractApiErrorMessage(
|
||||||
|
e,
|
||||||
|
isFirst
|
||||||
|
? '加载聊天记录失败,请稍后重试'
|
||||||
|
: '加载更多失败,请稍后重试',
|
||||||
|
)
|
||||||
|
ElMessage.error({ message, grouping: true })
|
||||||
|
} finally {
|
||||||
|
if (activeController === controller) {
|
||||||
|
loading.value = false
|
||||||
|
loadingMore.value = false
|
||||||
|
activeController = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 顶部「加载更早」按钮 → 用 prevCursor 拉更旧的一页。 */
|
||||||
|
function loadMore() {
|
||||||
|
if (loadingMore.value || allLoaded.value) return
|
||||||
|
const cursor = prevCursor.value
|
||||||
|
if (!cursor) return
|
||||||
|
void fetchPage(cursor)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 气泡下说明:`sender · time`(sender 空时省略)。
|
||||||
|
*/
|
||||||
|
function caption(m: ChatMessage): string {
|
||||||
|
const t = formatTime(m.sendTime)
|
||||||
|
const sender = m.sender?.trim()
|
||||||
|
return sender ? `${sender} · ${t}` : t
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 允许渲染 <img> 的图片域名白名单(防 XSS:只信任 alibaba 官方 CDN)。 */
|
||||||
|
const IMG_HOSTS = ['atmgateway-client.alibaba.com', 'alicdn.com', 'alibaba.com']
|
||||||
|
|
||||||
|
/** content 以 img: 前缀开头的是图片/表情消息(扩展 apiMsgToChat 提取的 src)。 */
|
||||||
|
function isImgMsg(c: string | null): boolean {
|
||||||
|
return typeof c === 'string' && c.startsWith('img:')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 从 img:url 提取 src 并校验白名单;非白名单返回空串(不渲染,防 XSS)。 */
|
||||||
|
function imgSrc(c: string): string {
|
||||||
|
let src = c.slice(4)
|
||||||
|
if (src.startsWith('//')) src = 'https:' + src
|
||||||
|
try {
|
||||||
|
const u = new URL(src)
|
||||||
|
if (!IMG_HOSTS.some(h => u.hostname === h || u.hostname.endsWith('.' + h))) return ''
|
||||||
|
return src
|
||||||
|
} catch {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** content 以 file: 开头的是文件卡片消息(PDF/Word/视频等),格式 `file:名称|url`。 */
|
||||||
|
function isFileMsg(c: string | null): boolean {
|
||||||
|
return typeof c === 'string' && c.startsWith('file:')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 从 file:名称|url 提取名称 + 校验白名单后的 URL(非白名单 url 置空,不渲染链接防 XSS)。 */
|
||||||
|
function fileMeta(c: string): { name: string; url: string } {
|
||||||
|
const rest = c.slice(5)
|
||||||
|
const idx = rest.indexOf('|')
|
||||||
|
const name = idx >= 0 ? rest.slice(0, idx) : '文件'
|
||||||
|
let url = idx >= 0 ? rest.slice(idx + 1) : ''
|
||||||
|
if (url.startsWith('//')) url = 'https:' + url
|
||||||
|
try {
|
||||||
|
const u = new URL(url)
|
||||||
|
if (!IMG_HOSTS.some(h => u.hostname === h || u.hostname.endsWith('.' + h))) return { name, url: '' }
|
||||||
|
} catch {
|
||||||
|
return { name, url: '' }
|
||||||
|
}
|
||||||
|
return { name, url }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** content 以 video: 开头的是视频消息,格式 video:url。 */
|
||||||
|
function isVideoMsg(c: string | null): boolean {
|
||||||
|
return typeof c === 'string' && c.startsWith('video:')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 从 video:url 提取 src + 校验白名单(同 img);非白名单返回空。 */
|
||||||
|
function videoSrc(c: string): string {
|
||||||
|
let src = c.slice(6)
|
||||||
|
if (src.startsWith('//')) src = 'https:' + src
|
||||||
|
try {
|
||||||
|
const u = new URL(src)
|
||||||
|
if (!IMG_HOSTS.some(h => u.hostname === h || u.hostname.endsWith('.' + h))) return ''
|
||||||
|
return src
|
||||||
|
} catch {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** content 本身就是纯 http(s) URL(非 img:/file:/video: 前缀)→ 渲染成外链。 */
|
||||||
|
function isUrlMsg(c: string | null): boolean {
|
||||||
|
if (typeof c !== 'string') return false
|
||||||
|
return /^https?:\/\/\S+$/i.test(c)
|
||||||
|
}
|
||||||
|
function urlHref(c: string): string {
|
||||||
|
// 只允许 http/https(挡 javascript:/data: 等 XSS scheme)
|
||||||
|
return /^https?:\/\//i.test(c) ? c : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
void fetchPage()
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
activeController?.abort()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<el-card class="chat-card" shadow="never">
|
||||||
|
<template #header>
|
||||||
|
<div class="chat-header">
|
||||||
|
<span class="card-title">聊天记录</span>
|
||||||
|
<span class="chat-count">{{ messages.length }} 条</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div v-loading="loading" class="chat-body">
|
||||||
|
<!--
|
||||||
|
DynamicScroller (vue-virtual-scroller v3) 自动测量每项高度,可变气泡
|
||||||
|
(长 MEDIUMTEXT)正确渲染。`keyField="messageId"` 匹配 ChatMessage 唯一 id。
|
||||||
|
`min-item-size` 是首次测量前的悲观下限(px),提升首屏 scroll 稳定性。
|
||||||
|
|
||||||
|
方向:items 升序(顶旧底新),ref 用于 scrollToBottom(首屏到底)+
|
||||||
|
scrollToItem(prepend 后保持视角)。顶部 #before slot 放「加载更早」。
|
||||||
|
-->
|
||||||
|
<DynamicScroller
|
||||||
|
v-if="messages.length > 0"
|
||||||
|
ref="scroller"
|
||||||
|
:items="messages"
|
||||||
|
:min-item-size="60"
|
||||||
|
key-field="messageId"
|
||||||
|
class="scroller"
|
||||||
|
>
|
||||||
|
<template #before>
|
||||||
|
<div class="load-more-row">
|
||||||
|
<el-button
|
||||||
|
v-if="!allLoaded"
|
||||||
|
size="small"
|
||||||
|
:loading="loadingMore"
|
||||||
|
:disabled="loadingMore"
|
||||||
|
@click="loadMore"
|
||||||
|
>
|
||||||
|
加载更早的消息
|
||||||
|
</el-button>
|
||||||
|
<span v-else class="load-more-end">已到顶</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #default="{ item, index, active }">
|
||||||
|
<DynamicScrollerItem
|
||||||
|
:item="item"
|
||||||
|
:active="active"
|
||||||
|
:data-index="index"
|
||||||
|
>
|
||||||
|
<!--
|
||||||
|
XSS 注意:content 是买家输入文本。只用 {{ }} 插值,绝不用 v-html。
|
||||||
|
-->
|
||||||
|
<div
|
||||||
|
v-if="item.direction === 'system'"
|
||||||
|
class="system-row"
|
||||||
|
>
|
||||||
|
<span class="system-text">{{ item.content }}</span>
|
||||||
|
<span class="system-time">{{ formatTime(item.sendTime) }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="bubble-row"
|
||||||
|
:class="item.direction === 'outgoing' ? 'is-outgoing' : 'is-incoming'"
|
||||||
|
>
|
||||||
|
<div class="bubble">
|
||||||
|
<el-image
|
||||||
|
v-if="isImgMsg(item.content)"
|
||||||
|
:src="imgSrc(item.content)"
|
||||||
|
:preview-src-list="[imgSrc(item.content)]"
|
||||||
|
preview-teleported
|
||||||
|
fit="cover"
|
||||||
|
hide-on-click-modal
|
||||||
|
class="bubble-image"
|
||||||
|
/>
|
||||||
|
<a
|
||||||
|
v-else-if="isFileMsg(item.content)"
|
||||||
|
:href="fileMeta(item.content).url || undefined"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="bubble-file"
|
||||||
|
:class="{ 'is-link': !!fileMeta(item.content).url }"
|
||||||
|
>📎 {{ fileMeta(item.content).name }}</a>
|
||||||
|
<video
|
||||||
|
v-else-if="isVideoMsg(item.content) && videoSrc(item.content)"
|
||||||
|
:src="videoSrc(item.content)"
|
||||||
|
controls
|
||||||
|
preload="metadata"
|
||||||
|
class="bubble-video"
|
||||||
|
></video>
|
||||||
|
<a
|
||||||
|
v-else-if="isUrlMsg(item.content) && urlHref(item.content)"
|
||||||
|
:href="urlHref(item.content)"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="bubble-link"
|
||||||
|
>🔗 {{ item.content }}</a>
|
||||||
|
<div v-else class="bubble-content">{{ item.content }}</div>
|
||||||
|
<div
|
||||||
|
v-if="item.translation"
|
||||||
|
class="bubble-translation"
|
||||||
|
>
|
||||||
|
{{ item.translation }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="bubble-caption">{{ caption(item) }}</div>
|
||||||
|
</div>
|
||||||
|
</DynamicScrollerItem>
|
||||||
|
</template>
|
||||||
|
</DynamicScroller>
|
||||||
|
|
||||||
|
<!-- 空状态(首页返回 [] 或加载中)。 -->
|
||||||
|
<el-empty
|
||||||
|
v-else-if="!loading"
|
||||||
|
description="暂无聊天记录"
|
||||||
|
:image-size="80"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.chat-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-title {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-count {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 固定高度滚动容器,给 scroller 一个有界框做虚拟化。480px 约可见 6-8 条气泡。 */
|
||||||
|
.chat-body {
|
||||||
|
min-height: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scroller {
|
||||||
|
height: 480px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scroller :deep(.vue-recycle-scroller__item-view) {
|
||||||
|
padding: 4px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- 系统消息:居中灰字,无气泡 ---- */
|
||||||
|
.system-row {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
padding: 6px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.system-text {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
background: var(--el-fill-color-light);
|
||||||
|
padding: 2px 10px;
|
||||||
|
border-radius: 10px;
|
||||||
|
max-width: 80%;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.system-time {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--el-text-color-placeholder);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- 聊天气泡 ---- */
|
||||||
|
.bubble-row {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 2px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bubble-row.is-incoming {
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bubble-row.is-outgoing {
|
||||||
|
align-items: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bubble {
|
||||||
|
max-width: 70%;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.5;
|
||||||
|
/* 保留 MEDIUMTEXT 换行,不渲染任何 HTML。 */
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 图片/表情消息(img:url,el-image 点击放大预览)*/
|
||||||
|
.bubble-image {
|
||||||
|
max-width: 120px;
|
||||||
|
max-height: 120px;
|
||||||
|
border-radius: 6px;
|
||||||
|
display: block;
|
||||||
|
cursor: zoom-in;
|
||||||
|
}
|
||||||
|
.bubble-image :deep(.el-image__inner) {
|
||||||
|
max-width: 120px;
|
||||||
|
max-height: 120px;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: zoom-in;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 视频消息(video:url,<video controls> 点击播放)*/
|
||||||
|
.bubble-video {
|
||||||
|
max-width: 240px;
|
||||||
|
max-height: 240px;
|
||||||
|
border-radius: 6px;
|
||||||
|
display: block;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 纯 URL 链接消息(渲染成外链)*/
|
||||||
|
.bubble-link {
|
||||||
|
display: inline-block;
|
||||||
|
max-width: 100%;
|
||||||
|
color: var(--el-color-primary);
|
||||||
|
text-decoration: underline;
|
||||||
|
word-break: break-all;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.bubble-link:hover {
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
.is-outgoing .bubble-link {
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 文件卡片消息(file:名称|url,渲染成可点击链接)*/
|
||||||
|
.bubble-file {
|
||||||
|
display: inline-block;
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
border: 1px solid rgba(0, 0, 0, 0.12);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
word-break: break-all;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
.bubble-file.is-link {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.bubble-file.is-link:hover {
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
.is-outgoing .bubble-file {
|
||||||
|
border-color: rgba(255, 255, 255, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.is-incoming .bubble {
|
||||||
|
background: var(--el-fill-color);
|
||||||
|
color: var(--el-text-color-primary);
|
||||||
|
border-top-left-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.is-outgoing .bubble {
|
||||||
|
background: var(--el-color-primary);
|
||||||
|
color: #fff;
|
||||||
|
border-top-right-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bubble-translation {
|
||||||
|
margin-top: 6px;
|
||||||
|
padding-top: 6px;
|
||||||
|
border-top: 1px solid rgba(0, 0, 0, 0.08);
|
||||||
|
font-size: 12px;
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
.is-outgoing .bubble-translation {
|
||||||
|
border-top-color: rgba(255, 255, 255, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bubble-caption {
|
||||||
|
margin-top: 3px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--el-text-color-placeholder);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- 顶部「加载更早」(#before slot)---- */
|
||||||
|
.load-more-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
padding: 12px 0;
|
||||||
|
min-height: 44px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.load-more-end {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--el-text-color-placeholder);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
const auth = useAuthStore()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* el-menu active highlight. Map detail sub-paths back to their parent menu
|
||||||
|
* item so e.g. `/inquiries/123` keeps the 「询盘列表」 entry highlighted.
|
||||||
|
*/
|
||||||
|
const activeMenu = computed(() => {
|
||||||
|
const path = route.path
|
||||||
|
if (path.startsWith('/inquiries')) return '/inquiries'
|
||||||
|
if (path.startsWith('/stats')) return '/stats'
|
||||||
|
return path
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Confirm then drop the token and head to /login. The re-entry guard in the
|
||||||
|
* router already redirects authed users away from /login, so clearing the
|
||||||
|
* token first is what actually makes the navigation land on the form.
|
||||||
|
*/
|
||||||
|
async function handleLogout() {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm('确定要登出吗?', '登出', {
|
||||||
|
confirmButtonText: '登出',
|
||||||
|
cancelButtonText: '取消',
|
||||||
|
type: 'warning',
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
// User cancelled the confirm — no-op.
|
||||||
|
return
|
||||||
|
}
|
||||||
|
auth.clearToken()
|
||||||
|
ElMessage.success('已登出')
|
||||||
|
await router.push('/login')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<el-container class="layout">
|
||||||
|
<el-aside width="200px" class="layout-aside">
|
||||||
|
<div class="brand">Alibb 后台</div>
|
||||||
|
<el-menu
|
||||||
|
:default-active="activeMenu"
|
||||||
|
router
|
||||||
|
class="layout-menu"
|
||||||
|
background-color="#304156"
|
||||||
|
text-color="#bfcbd9"
|
||||||
|
active-text-color="#409eff"
|
||||||
|
>
|
||||||
|
<el-menu-item index="/inquiries">
|
||||||
|
<el-icon><Document /></el-icon>
|
||||||
|
<span>询盘列表</span>
|
||||||
|
</el-menu-item>
|
||||||
|
<el-menu-item index="/stats">
|
||||||
|
<el-icon><DataLine /></el-icon>
|
||||||
|
<span>统计</span>
|
||||||
|
</el-menu-item>
|
||||||
|
</el-menu>
|
||||||
|
</el-aside>
|
||||||
|
|
||||||
|
<el-container>
|
||||||
|
<el-header class="layout-header">
|
||||||
|
<div class="header-user">
|
||||||
|
<span class="username">{{ auth.username ?? '未登录' }}</span>
|
||||||
|
<el-button link type="primary" @click="handleLogout">登出</el-button>
|
||||||
|
</div>
|
||||||
|
</el-header>
|
||||||
|
|
||||||
|
<el-main class="layout-main">
|
||||||
|
<router-view />
|
||||||
|
</el-main>
|
||||||
|
</el-container>
|
||||||
|
</el-container>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.layout {
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout-aside {
|
||||||
|
background: #304156;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand {
|
||||||
|
height: 60px;
|
||||||
|
line-height: 60px;
|
||||||
|
text-align: center;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
border-bottom: 1px solid #1f2d3d;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* el-menu fills the rest of the sidebar; remove its default bottom border. */
|
||||||
|
.layout-menu {
|
||||||
|
flex: 1;
|
||||||
|
border-right: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
background: #fff;
|
||||||
|
border-bottom: 1px solid #e6e6e6;
|
||||||
|
padding: 0 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-user {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.username {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #303133;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout-main {
|
||||||
|
background: #f5f7fa;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { createApp } from 'vue'
|
||||||
|
import { createPinia } from 'pinia'
|
||||||
|
import ElementPlus from 'element-plus'
|
||||||
|
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
|
||||||
|
import 'element-plus/dist/index.css'
|
||||||
|
// vue-virtual-scroller v3 (Vue 3). The CSS is REQUIRED — without it the
|
||||||
|
// scrollers' spacer/transform sizing collapses and items mis-render. The
|
||||||
|
// default export is the install plugin; DynamicScroller / DynamicScrollerItem
|
||||||
|
// are also named exports, but `app.use(VueVirtualScroller)` registers them
|
||||||
|
// globally so we can use <DynamicScroller> / <DynamicScrollerItem> in
|
||||||
|
// ChatList.vue's template without per-file imports.
|
||||||
|
import VueVirtualScroller from 'vue-virtual-scroller'
|
||||||
|
import 'vue-virtual-scroller/dist/vue-virtual-scroller.css'
|
||||||
|
|
||||||
|
import App from './App.vue'
|
||||||
|
import router from './router'
|
||||||
|
|
||||||
|
const app = createApp(App)
|
||||||
|
|
||||||
|
app.use(createPinia())
|
||||||
|
app.use(router)
|
||||||
|
app.use(ElementPlus)
|
||||||
|
app.use(VueVirtualScroller)
|
||||||
|
|
||||||
|
// Register all Element Plus icons globally (available as <el-icon><IconName /></el-icon>).
|
||||||
|
// TODO(perf): if bundle size matters later, switch to
|
||||||
|
// unplugin-vue-components + unplugin-auto-import with ElementPlusResolver
|
||||||
|
// to drop unused icons and tree-shake components.
|
||||||
|
// TODO(perf): echarts is imported in full in Stats.vue (~1.1MB chunk). Switch
|
||||||
|
// to 'echarts/core' + explicit chart/component registration (pie/bar/line +
|
||||||
|
// tooltip/legend/grid) if bundle matters.
|
||||||
|
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
|
||||||
|
app.component(key, component)
|
||||||
|
}
|
||||||
|
|
||||||
|
app.mount('#app')
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import type { RouteRecordRaw } from 'vue-router'
|
||||||
|
import { createRouter, createWebHistory } from 'vue-router'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
|
||||||
|
const routes: RouteRecordRaw[] = [
|
||||||
|
// Public login screen, rendered OUTSIDE the app layout (no sidebar/header).
|
||||||
|
{
|
||||||
|
path: '/login',
|
||||||
|
name: 'login',
|
||||||
|
component: () => import('@/views/Login.vue'),
|
||||||
|
meta: { public: true },
|
||||||
|
},
|
||||||
|
// App shell: sidebar + header chrome live in MainLayout; every authenticated
|
||||||
|
// page renders inside its <router-view/>. Children are lazy/scaffolded.
|
||||||
|
{
|
||||||
|
path: '/',
|
||||||
|
component: () => import('@/layouts/MainLayout.vue'),
|
||||||
|
children: [
|
||||||
|
// `/` → `/inquiries` (keeps the default-landing behaviour from Task 1).
|
||||||
|
// Resolved before the beforeEach guard runs, so the guard still sees a
|
||||||
|
// concrete app path.
|
||||||
|
{ path: '', redirect: '/inquiries' },
|
||||||
|
{
|
||||||
|
path: 'inquiries',
|
||||||
|
name: 'inquiries',
|
||||||
|
component: () => import('@/views/InquiryList.vue'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Detail view (Task 5): customer info + behavior card + virtual-scroll
|
||||||
|
// chat. `:id` is the inquiryId business key (matches how the list row
|
||||||
|
// click builds the URL and how the backend resolves /api/inquiries/:id).
|
||||||
|
path: 'inquiries/:id',
|
||||||
|
name: 'inquiry-detail',
|
||||||
|
component: () => import('@/views/InquiryDetail.vue'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'stats',
|
||||||
|
name: 'stats',
|
||||||
|
component: () => import('@/views/Stats.vue'),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const router = createRouter({
|
||||||
|
history: createWebHistory(import.meta.env.BASE_URL),
|
||||||
|
routes,
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Navigation guard:
|
||||||
|
* - Unauthenticated users hitting an app route → bounce to /login.
|
||||||
|
* - Authenticated users hitting /login → bounce to /inquiries (skip the form).
|
||||||
|
*
|
||||||
|
* vue-router fires `beforeEach` for child navigations too, so nesting the app
|
||||||
|
* pages under MainLayout (added in Task 3) needs no change here: `to.meta` is
|
||||||
|
* the merged meta across the matched records, and only `/login` carries
|
||||||
|
* `meta.public`, so every layout child stays gated.
|
||||||
|
*
|
||||||
|
* A route is considered "public" when `meta.public` is true (only /login for now).
|
||||||
|
*/
|
||||||
|
router.beforeEach((to) => {
|
||||||
|
const auth = useAuthStore()
|
||||||
|
if (!to.meta.public && !auth.isAuthenticated) {
|
||||||
|
// Always record where the user was headed so Login.vue can return them
|
||||||
|
// there (open-redirect-safe — Login.vue validates the value).
|
||||||
|
return { path: '/login', query: { redirect: to.fullPath } }
|
||||||
|
}
|
||||||
|
if (to.path === '/login' && auth.isAuthenticated) {
|
||||||
|
return { path: '/inquiries' }
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
export default router
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import { beforeEach, describe, expect, it } from 'vitest'
|
||||||
|
import { createPinia, setActivePinia } from 'pinia'
|
||||||
|
import { TOKEN_KEY, useAuthStore } from '@/stores/auth'
|
||||||
|
|
||||||
|
describe('auth store', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.clear()
|
||||||
|
setActivePinia(createPinia())
|
||||||
|
})
|
||||||
|
|
||||||
|
it('initializes token from localStorage', () => {
|
||||||
|
localStorage.setItem(TOKEN_KEY, 'preexisting-token')
|
||||||
|
const auth = useAuthStore()
|
||||||
|
expect(auth.token).toBe('preexisting-token')
|
||||||
|
expect(auth.isAuthenticated).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('initializes with null when localStorage is empty', () => {
|
||||||
|
const auth = useAuthStore()
|
||||||
|
expect(auth.token).toBeNull()
|
||||||
|
expect(auth.isAuthenticated).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('setToken persists to both ref and localStorage', () => {
|
||||||
|
const auth = useAuthStore()
|
||||||
|
auth.setToken('abc123')
|
||||||
|
expect(auth.token).toBe('abc123')
|
||||||
|
expect(localStorage.getItem(TOKEN_KEY)).toBe('abc123')
|
||||||
|
expect(auth.isAuthenticated).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('setToken(null) clears both ref and localStorage', () => {
|
||||||
|
const auth = useAuthStore()
|
||||||
|
auth.setToken('abc123')
|
||||||
|
auth.setToken(null)
|
||||||
|
expect(auth.token).toBeNull()
|
||||||
|
expect(localStorage.getItem(TOKEN_KEY)).toBeNull()
|
||||||
|
expect(auth.isAuthenticated).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('clearToken removes both ref and localStorage', () => {
|
||||||
|
const auth = useAuthStore()
|
||||||
|
auth.setToken('abc123')
|
||||||
|
auth.clearToken()
|
||||||
|
expect(auth.token).toBeNull()
|
||||||
|
expect(localStorage.getItem(TOKEN_KEY)).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('isAuthenticated is reactive to setToken', () => {
|
||||||
|
const auth = useAuthStore()
|
||||||
|
expect(auth.isAuthenticated).toBe(false)
|
||||||
|
auth.setToken('x')
|
||||||
|
expect(auth.isAuthenticated).toBe(true)
|
||||||
|
auth.clearToken()
|
||||||
|
expect(auth.isAuthenticated).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('setToken with undefined clears (defensive)', () => {
|
||||||
|
const auth = useAuthStore()
|
||||||
|
auth.setToken('x')
|
||||||
|
auth.setToken(undefined)
|
||||||
|
expect(auth.token).toBeNull()
|
||||||
|
expect(localStorage.getItem(TOKEN_KEY)).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('auth store — username (derived from JWT)', () => {
|
||||||
|
// base64url payload with `u: 'admin'` (mirrors backend login JWT shape).
|
||||||
|
function tokenWithU(u: string | number): string {
|
||||||
|
const b64 = btoa(JSON.stringify({ sub: '1', u: u, iat: 1, exp: 2 }))
|
||||||
|
const b64url = b64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
|
||||||
|
return `header.${b64url}.signature`
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.clear()
|
||||||
|
setActivePinia(createPinia())
|
||||||
|
})
|
||||||
|
|
||||||
|
it('is null when there is no token', () => {
|
||||||
|
const auth = useAuthStore()
|
||||||
|
expect(auth.username).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('decodes the `u` claim from the token', () => {
|
||||||
|
const auth = useAuthStore()
|
||||||
|
auth.setToken(tokenWithU('admin'))
|
||||||
|
expect(auth.username).toBe('admin')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('is null when the token is malformed', () => {
|
||||||
|
const auth = useAuthStore()
|
||||||
|
auth.setToken('not-a-real-jwt')
|
||||||
|
expect(auth.username).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reactively updates when the token changes', () => {
|
||||||
|
const auth = useAuthStore()
|
||||||
|
auth.setToken(tokenWithU('admin'))
|
||||||
|
expect(auth.username).toBe('admin')
|
||||||
|
auth.clearToken()
|
||||||
|
expect(auth.username).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { decodeJwtUsername } from '@/utils/jwt'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Single source of truth for the localStorage key that holds the JWT.
|
||||||
|
* `request.ts` reads the token directly from localStorage (so the transport
|
||||||
|
* layer has no Pinia dependency and works before the store is installed),
|
||||||
|
* and this store mirrors the same key. Keep them in sync here.
|
||||||
|
*/
|
||||||
|
export const TOKEN_KEY = 'alibb_admin_token'
|
||||||
|
// TODO(security): JWT lives in localStorage (XSS-exposed). Acceptable for an
|
||||||
|
// internal admin MVP. Migrate to httpOnly cookie if this tool is ever exposed
|
||||||
|
// to the public internet or non-employee accounts.
|
||||||
|
|
||||||
|
function readToken(): string | null {
|
||||||
|
return localStorage.getItem(TOKEN_KEY)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auth store. Owns token state (and a derived display username).
|
||||||
|
* The token is persisted to localStorage so it survives reloads; every
|
||||||
|
* mutation goes through {@link setToken} / {@link clearToken} so the ref and
|
||||||
|
* localStorage never drift apart.
|
||||||
|
*/
|
||||||
|
export const useAuthStore = defineStore('auth', () => {
|
||||||
|
const token = ref<string | null>(readToken())
|
||||||
|
|
||||||
|
const isAuthenticated = computed(() => !!token.value)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Display username decoded from the JWT `u` claim (client-side only — for
|
||||||
|
* the header chip; the backend is still authoritative). `null` when there
|
||||||
|
* is no token or it is malformed. Recomputed reactively when `token` changes.
|
||||||
|
*/
|
||||||
|
const username = computed(() =>
|
||||||
|
token.value ? decodeJwtUsername(token.value) : null,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Persist `t` to both the ref and localStorage. Pass null/undefined to clear. */
|
||||||
|
function setToken(t: string | null | undefined) {
|
||||||
|
token.value = t ?? null
|
||||||
|
if (t) {
|
||||||
|
localStorage.setItem(TOKEN_KEY, t)
|
||||||
|
} else {
|
||||||
|
localStorage.removeItem(TOKEN_KEY)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove the token from both the ref and localStorage.
|
||||||
|
* NOTE: the 401 interceptor in `api/request.ts` clears localStorage directly
|
||||||
|
* (no Pinia dependency), so it does NOT route through this action. If you
|
||||||
|
* add a side-effect here (e.g. analytics, resetting other store state),
|
||||||
|
* mirror it on the clear path in request.ts so both routes stay in sync.
|
||||||
|
*/
|
||||||
|
function clearToken() {
|
||||||
|
setToken(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
return { token, isAuthenticated, username, setToken, clearToken }
|
||||||
|
})
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { parseFilenameFromContentDisposition } from '@/utils/download'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unit tests for the pure `Content-Disposition` filename parser.
|
||||||
|
*
|
||||||
|
* `downloadBlob` is a DOM side-effect and intentionally NOT covered here —
|
||||||
|
* it's verified manually via the live button-click flow.
|
||||||
|
*
|
||||||
|
* These cases lock in the high-value shape variants the backend (and proxies
|
||||||
|
* in front of it) can emit: quoted, unquoted, missing, extra params, and the
|
||||||
|
* RFC 5987 `filename*` form. The backend today emits a fixed
|
||||||
|
* `attachment; filename="inquiries.csv"` but a future nginx/CDN rewrite could
|
||||||
|
* produce any of these; the parser must be robust to all of them so the
|
||||||
|
* downloaded file gets the right name (not `inquiries.csv` accidentally
|
||||||
|
* becoming `null` → falling back to the hardcoded default, or worse `""`).
|
||||||
|
*/
|
||||||
|
describe('parseFilenameFromContentDisposition', () => {
|
||||||
|
describe('quoted filename', () => {
|
||||||
|
it('extracts a quoted filename (the shape the backend emits)', () => {
|
||||||
|
expect(
|
||||||
|
parseFilenameFromContentDisposition(
|
||||||
|
'attachment; filename="inquiries.csv"',
|
||||||
|
),
|
||||||
|
).toBe('inquiries.csv')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handles a filename containing spaces inside the quotes', () => {
|
||||||
|
expect(
|
||||||
|
parseFilenameFromContentDisposition(
|
||||||
|
'attachment; filename="inquiry export.csv"',
|
||||||
|
),
|
||||||
|
).toBe('inquiry export.csv')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handles a filename containing dots', () => {
|
||||||
|
expect(
|
||||||
|
parseFilenameFromContentDisposition(
|
||||||
|
'attachment; filename="2026.07.inquiries.csv"',
|
||||||
|
),
|
||||||
|
).toBe('2026.07.inquiries.csv')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handles a semicolon inside the quoted filename', () => {
|
||||||
|
// Locks in that the regex `[^"]*` (NOT `[^;"]*`) captures everything up
|
||||||
|
// to the closing quote. A future "simplification" to `[^;"]*` would
|
||||||
|
// silently truncate this to `a` and ship a misnamed file. Real backends
|
||||||
|
// / proxies can emit semicolons in filenames, so this is a real case,
|
||||||
|
// not a paranoid one.
|
||||||
|
expect(
|
||||||
|
parseFilenameFromContentDisposition(
|
||||||
|
'attachment; filename="a;b.csv"',
|
||||||
|
),
|
||||||
|
).toBe('a;b.csv')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('unquoted filename', () => {
|
||||||
|
it('extracts an unquoted filename', () => {
|
||||||
|
expect(
|
||||||
|
parseFilenameFromContentDisposition('attachment; filename=inquiries.csv'),
|
||||||
|
).toBe('inquiries.csv')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('trims whitespace around an unquoted filename', () => {
|
||||||
|
expect(
|
||||||
|
parseFilenameFromContentDisposition(
|
||||||
|
'attachment; filename= inquiries.csv ',
|
||||||
|
),
|
||||||
|
).toBe('inquiries.csv')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('extra params / ordering', () => {
|
||||||
|
it('extracts the filename when extra params follow it', () => {
|
||||||
|
expect(
|
||||||
|
parseFilenameFromContentDisposition(
|
||||||
|
'attachment; filename="x.csv"; size=123',
|
||||||
|
),
|
||||||
|
).toBe('x.csv')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('extracts the filename when extra params precede it', () => {
|
||||||
|
expect(
|
||||||
|
parseFilenameFromContentDisposition(
|
||||||
|
'attachment; size=123; filename="y.csv"',
|
||||||
|
),
|
||||||
|
).toBe('y.csv')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handles the type token being inline before disposition type', () => {
|
||||||
|
// Some servers emit `inline`/`attachment` variations; the parser only
|
||||||
|
// cares about the `filename=` directive.
|
||||||
|
expect(
|
||||||
|
parseFilenameFromContentDisposition(
|
||||||
|
'inline; filename="preview.csv"',
|
||||||
|
),
|
||||||
|
).toBe('preview.csv')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('case-insensitivity', () => {
|
||||||
|
it('matches `FILENAME` (uppercase)', () => {
|
||||||
|
expect(
|
||||||
|
parseFilenameFromContentDisposition(
|
||||||
|
'attachment; FILENAME="upper.csv"',
|
||||||
|
),
|
||||||
|
).toBe('upper.csv')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('matches mixed-case `FileName`', () => {
|
||||||
|
expect(
|
||||||
|
parseFilenameFromContentDisposition(
|
||||||
|
'attachment; FileName="mixed.csv"',
|
||||||
|
),
|
||||||
|
).toBe('mixed.csv')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('RFC 5987 filename*', () => {
|
||||||
|
it('extracts a percent-encoded filename* (preferred over filename)', () => {
|
||||||
|
// RFC 5987 form: `filename*=charset'lang'value`. `inquiries.csv` has no
|
||||||
|
// special chars, but the value is still valid percent-encoded text.
|
||||||
|
expect(
|
||||||
|
parseFilenameFromContentDisposition(
|
||||||
|
"attachment; filename*=UTF-8''inquiries.csv",
|
||||||
|
),
|
||||||
|
).toBe('inquiries.csv')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('decodes percent-encoded bytes in filename*', () => {
|
||||||
|
// `report%20july.csv` → `report july.csv`.
|
||||||
|
expect(
|
||||||
|
parseFilenameFromContentDisposition(
|
||||||
|
"attachment; filename*=UTF-8''report%20july.csv",
|
||||||
|
),
|
||||||
|
).toBe('report july.csv')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('prefers filename* over a plain filename when both are present', () => {
|
||||||
|
expect(
|
||||||
|
parseFilenameFromContentDisposition(
|
||||||
|
'attachment; filename="legacy.csv"; filename*=UTF-8\'\'real.csv',
|
||||||
|
),
|
||||||
|
).toBe('real.csv')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('falls back to plain filename when filename* value is empty', () => {
|
||||||
|
// Defensive: an empty filename* shouldn't blank out a good filename.
|
||||||
|
expect(
|
||||||
|
parseFilenameFromContentDisposition(
|
||||||
|
"attachment; filename*=UTF-8''; filename=\"fallback.csv\"",
|
||||||
|
),
|
||||||
|
).toBe('fallback.csv')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('falls back to the raw filename* value if percent-decoding fails', () => {
|
||||||
|
// `%2` is a truncated percent-escape; decodeURIComponent throws on it.
|
||||||
|
// The parser must NOT crash — it should hand back the raw string so the
|
||||||
|
// download still proceeds with SOME filename.
|
||||||
|
expect(
|
||||||
|
parseFilenameFromContentDisposition(
|
||||||
|
"attachment; filename*=UTF-8''bad%2Z.csv",
|
||||||
|
),
|
||||||
|
).toBe('bad%2Z.csv')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('missing / unparseable', () => {
|
||||||
|
it('returns null when the header is undefined', () => {
|
||||||
|
expect(parseFilenameFromContentDisposition(undefined)).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns null when the header is null', () => {
|
||||||
|
expect(parseFilenameFromContentDisposition(null)).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns null when the header is an empty string', () => {
|
||||||
|
expect(parseFilenameFromContentDisposition('')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns null when there is no filename directive', () => {
|
||||||
|
expect(parseFilenameFromContentDisposition('attachment')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns null when filename= is present but empty inside quotes', () => {
|
||||||
|
expect(
|
||||||
|
parseFilenameFromContentDisposition('attachment; filename=""'),
|
||||||
|
).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns null when filename= is present but whitespace-only', () => {
|
||||||
|
expect(
|
||||||
|
parseFilenameFromContentDisposition('attachment; filename= '),
|
||||||
|
).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns null for an unrelated header (e.g. Content-Type)', () => {
|
||||||
|
expect(
|
||||||
|
parseFilenameFromContentDisposition('text/csv; charset=utf-8'),
|
||||||
|
).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import dayjs from 'dayjs'
|
||||||
|
import {
|
||||||
|
dash,
|
||||||
|
extractApiErrorMessage,
|
||||||
|
formatTime,
|
||||||
|
} from '@/utils/format'
|
||||||
|
|
||||||
|
describe('dash', () => {
|
||||||
|
it.each([
|
||||||
|
['null', null, '—'],
|
||||||
|
['undefined', undefined, '—'],
|
||||||
|
['empty string', '', '—'],
|
||||||
|
['whitespace-only', ' ', '—'],
|
||||||
|
['single space', ' ', '—'],
|
||||||
|
['tab-only', '\t', '—'],
|
||||||
|
] as const)('returns "—" for blank input (%s)', (_label, input, expected) => {
|
||||||
|
expect(dash(input)).toBe(expected)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('preserves a non-empty string verbatim (no trimming of real content)', () => {
|
||||||
|
expect(dash('hello')).toBe('hello')
|
||||||
|
// Inner whitespace is preserved; only pure-whitespace collapses to "—".
|
||||||
|
expect(dash(' hello ')).toBe(' hello ')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('stringifies the number 0 to "0" (NOT "—" — 0 is a real value)', () => {
|
||||||
|
// Guards against a falsy-based implementation regressing: `0` is falsy in
|
||||||
|
// JS, but for a count-like field, "0" is meaningful and must render.
|
||||||
|
expect(dash(0)).toBe('0')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('stringifies other non-string values', () => {
|
||||||
|
expect(dash(42)).toBe('42')
|
||||||
|
expect(dash(true)).toBe('true')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('formatTime', () => {
|
||||||
|
it('formats a valid UTC ISO to local-TZ YYYY-MM-DD HH:mm', () => {
|
||||||
|
// Use a fixed UTC instant; dayjs renders in the runtime's local TZ. We
|
||||||
|
// assert the format shape + that the date portion matches the UTC date
|
||||||
|
// (valid for any TZ since the input is midnight UTC, which lands on the
|
||||||
|
// same calendar day in most zones).
|
||||||
|
const out = formatTime('2026-07-14T10:30:00.000Z')
|
||||||
|
expect(out).toMatch(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$/)
|
||||||
|
// Either 2026-07-14 (TZ >= UTC) or a same-or-adjacent day — pin the month
|
||||||
|
// so a regression to "Invalid Date" or raw ISO is caught.
|
||||||
|
expect(out.startsWith('2026-07-')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('matches dayjs(iso).format(...) for a representative timestamp', () => {
|
||||||
|
// Pin the exact output for one timestamp so a format-string typo
|
||||||
|
// (e.g. wrong placeholder) trips this test. We compare against dayjs
|
||||||
|
// directly to stay TZ-agnostic.
|
||||||
|
const iso = '2025-01-02T03:04:05.000Z'
|
||||||
|
expect(formatTime(iso)).toBe(dayjs(iso).format('YYYY-MM-DD HH:mm'))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns the default fallback "—" for null', () => {
|
||||||
|
expect(formatTime(null)).toBe('—')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns the default fallback "—" for undefined', () => {
|
||||||
|
expect(formatTime(undefined)).toBe('—')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns the default fallback "—" for empty string', () => {
|
||||||
|
expect(formatTime('')).toBe('—')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns a custom fallback when provided', () => {
|
||||||
|
expect(formatTime(null, 'n/a')).toBe('n/a')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns the fallback for an unparseable date string', () => {
|
||||||
|
expect(formatTime('not-a-date')).toBe('—')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('extractApiErrorMessage', () => {
|
||||||
|
it('returns the message from an object with a string message', () => {
|
||||||
|
expect(extractApiErrorMessage({ message: 'boom' }, 'fallback')).toBe('boom')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns the message from a normalized ApiError-shape object', () => {
|
||||||
|
// ApiError always carries `status: number` + `message: string`.
|
||||||
|
const apiErr = { status: 500, message: '服务器内部错误' }
|
||||||
|
expect(extractApiErrorMessage(apiErr, 'fallback')).toBe('服务器内部错误')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns the message from a native Error', () => {
|
||||||
|
expect(extractApiErrorMessage(new Error('network down'), 'fb')).toBe(
|
||||||
|
'network down',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns fallback for an object WITHOUT a message key', () => {
|
||||||
|
expect(extractApiErrorMessage({ code: 'X' }, 'fb')).toBe('fb')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns fallback when message is present but NOT a string', () => {
|
||||||
|
// Narrowing guard: a non-string message (e.g. an object) must not be
|
||||||
|
// stringified into "[object Object]" — fall back instead.
|
||||||
|
expect(extractApiErrorMessage({ message: { nested: 1 } }, 'fb')).toBe('fb')
|
||||||
|
expect(extractApiErrorMessage({ message: 42 }, 'fb')).toBe('fb')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns fallback for null', () => {
|
||||||
|
expect(extractApiErrorMessage(null, 'fb')).toBe('fb')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns fallback for a string error (non-object)', () => {
|
||||||
|
expect(extractApiErrorMessage('something broke', 'fb')).toBe('fb')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns fallback for undefined', () => {
|
||||||
|
expect(extractApiErrorMessage(undefined, 'fb')).toBe('fb')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import {
|
||||||
|
DEFAULT_INQUIRY_SORT,
|
||||||
|
buildListParams,
|
||||||
|
defaultFilterState,
|
||||||
|
} from '@/utils/inquiryParams'
|
||||||
|
import type { InquiryFilterState } from '@/utils/inquiryParams'
|
||||||
|
|
||||||
|
describe('buildListParams', () => {
|
||||||
|
describe('date range → full ISO datetimes (the 400 gotcha)', () => {
|
||||||
|
it('converts both ends of a picked range to full ISO via toISOString()', () => {
|
||||||
|
// Simulate el-date-picker `type="daterange"` output: two Date objects.
|
||||||
|
// Use fixed UTC instants so the test is timezone-independent.
|
||||||
|
const from = new Date('2026-07-14T00:00:00.000Z')
|
||||||
|
const to = new Date('2026-07-20T23:59:59.999Z')
|
||||||
|
const state = defaultFilterState({ dateRange: [from, to] })
|
||||||
|
|
||||||
|
const params = buildListParams(state)
|
||||||
|
|
||||||
|
expect(params.dateFrom).toBe('2026-07-14T00:00:00.000Z')
|
||||||
|
expect(params.dateTo).toBe('2026-07-20T23:59:59.999Z')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('emits NO dateFrom/dateTo keys when no range is picked', () => {
|
||||||
|
const params = buildListParams(defaultFilterState({ dateRange: null }))
|
||||||
|
expect(params).not.toHaveProperty('dateFrom')
|
||||||
|
expect(params).not.toHaveProperty('dateTo')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('never serializes a date as bare YYYY-MM-DD (would 400 backend zod check)', () => {
|
||||||
|
const params = buildListParams(
|
||||||
|
defaultFilterState({
|
||||||
|
dateRange: [
|
||||||
|
new Date('2026-07-14T00:00:00.000Z'),
|
||||||
|
new Date('2026-07-14T00:00:00.000Z'),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
// A bare date like "2026-07-14" would be a 400. Insist on the T/Z shape.
|
||||||
|
expect(params.dateFrom).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/)
|
||||||
|
expect(params.dateTo).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handles a half-empty range by sending only the set side', () => {
|
||||||
|
// el-date-picker normally yields [Date, Date] | null, but be defensive:
|
||||||
|
// a stale URL or partial state shouldn't stringify null into "null".
|
||||||
|
const state: InquiryFilterState = defaultFilterState({
|
||||||
|
dateRange: [new Date('2026-07-14T00:00:00.000Z'), null],
|
||||||
|
})
|
||||||
|
const params = buildListParams(state)
|
||||||
|
expect(params.dateFrom).toBe('2026-07-14T00:00:00.000Z')
|
||||||
|
expect(params).not.toHaveProperty('dateTo')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('omits BOTH dateFrom and dateTo when the range is [null, null]', () => {
|
||||||
|
// Defensive: a caller could clear the picker and end up with explicit
|
||||||
|
// null slots rather than a top-level null. No key should leak.
|
||||||
|
const state: InquiryFilterState = defaultFilterState({
|
||||||
|
dateRange: [null, null],
|
||||||
|
})
|
||||||
|
const params = buildListParams(state)
|
||||||
|
expect(params).not.toHaveProperty('dateFrom')
|
||||||
|
expect(params).not.toHaveProperty('dateTo')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('default state — no keys leak', () => {
|
||||||
|
it('defaultFilterState() builds params with ONLY page/size/sort', () => {
|
||||||
|
// Locks the "fresh view sends a clean query" contract: a freshly-mounted
|
||||||
|
// list must not send stale country/manager/status/q/dateFrom/dateTo
|
||||||
|
// params (which could happen if a future field were added with a non-
|
||||||
|
// empty default and the omission guard regressed).
|
||||||
|
const params = buildListParams(defaultFilterState())
|
||||||
|
expect(Object.keys(params).sort()).toEqual(['page', 'size', 'sort'])
|
||||||
|
expect(params).not.toHaveProperty('country')
|
||||||
|
expect(params).not.toHaveProperty('manager')
|
||||||
|
expect(params).not.toHaveProperty('status')
|
||||||
|
expect(params).not.toHaveProperty('q')
|
||||||
|
expect(params).not.toHaveProperty('dateFrom')
|
||||||
|
expect(params).not.toHaveProperty('dateTo')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('filter omission', () => {
|
||||||
|
it('omits empty string filters so axios does not send ?country=', () => {
|
||||||
|
const params = buildListParams(defaultFilterState())
|
||||||
|
expect(params).not.toHaveProperty('country')
|
||||||
|
expect(params).not.toHaveProperty('manager')
|
||||||
|
expect(params).not.toHaveProperty('status')
|
||||||
|
expect(params).not.toHaveProperty('q')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('includes only the non-empty filters that were set', () => {
|
||||||
|
const params = buildListParams(
|
||||||
|
defaultFilterState({ country: 'US', q: 'rfq-123' }),
|
||||||
|
)
|
||||||
|
expect(params.country).toBe('US')
|
||||||
|
expect(params.q).toBe('rfq-123')
|
||||||
|
expect(params).not.toHaveProperty('manager')
|
||||||
|
expect(params).not.toHaveProperty('status')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('page / size coercion', () => {
|
||||||
|
it('coerces numeric page/size to number type', () => {
|
||||||
|
const params = buildListParams(
|
||||||
|
defaultFilterState({ page: 3, size: 50 }),
|
||||||
|
)
|
||||||
|
expect(params.page).toBe(3)
|
||||||
|
expect(params.size).toBe(50)
|
||||||
|
expect(typeof params.page).toBe('number')
|
||||||
|
expect(typeof params.size).toBe('number')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('coerces string-shaped page/size from a future URL-source caller', () => {
|
||||||
|
const params = buildListParams(
|
||||||
|
defaultFilterState({ page: '2' as unknown as number, size: '10' as unknown as number }),
|
||||||
|
)
|
||||||
|
expect(params.page).toBe(2)
|
||||||
|
expect(params.size).toBe(10)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('drops page/size if non-numeric (avoids sending ?page=NaN → 400)', () => {
|
||||||
|
const params = buildListParams(
|
||||||
|
defaultFilterState({ page: 'abc' as unknown as number }),
|
||||||
|
)
|
||||||
|
expect(params).not.toHaveProperty('page')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('sort passthrough', () => {
|
||||||
|
it('passes the default sort through (update_time_desc)', () => {
|
||||||
|
const params = buildListParams(defaultFilterState())
|
||||||
|
expect(params.sort).toBe(DEFAULT_INQUIRY_SORT)
|
||||||
|
expect(params.sort).toBe('update_time_desc')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('passes a non-default sort through verbatim', () => {
|
||||||
|
const params = buildListParams(
|
||||||
|
defaultFilterState({ sort: 'update_time_desc' }),
|
||||||
|
)
|
||||||
|
expect(params.sort).toBe('update_time_desc')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('passes the ascending-create sort through verbatim', () => {
|
||||||
|
const params = buildListParams(
|
||||||
|
defaultFilterState({ sort: 'create_time_asc' }),
|
||||||
|
)
|
||||||
|
expect(params.sort).toBe('create_time_asc')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('full integration snapshot', () => {
|
||||||
|
it('builds a complete params object from a fully-populated state', () => {
|
||||||
|
const params = buildListParams(
|
||||||
|
defaultFilterState({
|
||||||
|
country: 'CN',
|
||||||
|
manager: 'alice',
|
||||||
|
status: 'new',
|
||||||
|
q: 'widget',
|
||||||
|
dateRange: [
|
||||||
|
new Date('2026-01-01T00:00:00.000Z'),
|
||||||
|
new Date('2026-06-30T00:00:00.000Z'),
|
||||||
|
],
|
||||||
|
sort: 'update_time_desc',
|
||||||
|
page: 2,
|
||||||
|
size: 25,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
expect(params).toEqual({
|
||||||
|
page: 2,
|
||||||
|
size: 25,
|
||||||
|
country: 'CN',
|
||||||
|
manager: 'alice',
|
||||||
|
status: 'new',
|
||||||
|
q: 'widget',
|
||||||
|
dateFrom: '2026-01-01T00:00:00.000Z',
|
||||||
|
dateTo: '2026-06-30T00:00:00.000Z',
|
||||||
|
sort: 'update_time_desc',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { decodeJwtUsername } from '@/utils/jwt'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a JWT-shaped string `header.payload.signature` where `payload` is the
|
||||||
|
* base64url encoding of `payloadObj`. Mirrors how the backend signs tokens and
|
||||||
|
* exercises the decoder's base64url handling (not just standard base64).
|
||||||
|
*/
|
||||||
|
function buildToken(payloadObj: Record<string, unknown>): string {
|
||||||
|
// Encode UTF-8 bytes first so non-ASCII (e.g. 管理员) round-trips; btoa
|
||||||
|
// alone can't take characters above U+00FF. Mirrors how the real backend
|
||||||
|
// would sign a UTF-8 JSON payload.
|
||||||
|
const bytes = new TextEncoder().encode(JSON.stringify(payloadObj))
|
||||||
|
let binary = ''
|
||||||
|
for (const b of bytes) binary += String.fromCharCode(b)
|
||||||
|
const b64 = btoa(binary)
|
||||||
|
// Standard base64 → base64url (-_ instead of +/, strip padding).
|
||||||
|
const b64url = b64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
|
||||||
|
return `header.${b64url}.signature`
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('decodeJwtUsername', () => {
|
||||||
|
it('returns the username (`u` claim) from a valid base64url JWT', () => {
|
||||||
|
const token = buildToken({ sub: '1', u: 'admin', iat: 1, exp: 2 })
|
||||||
|
expect(decodeJwtUsername(token)).toBe('admin')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns null when there is no `u` claim', () => {
|
||||||
|
const token = buildToken({ sub: '1', iat: 1, exp: 2 })
|
||||||
|
expect(decodeJwtUsername(token)).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns null when `u` is present but not a string', () => {
|
||||||
|
const token = buildToken({ u: 123 })
|
||||||
|
expect(decodeJwtUsername(token)).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns null for garbage with fewer than three segments', () => {
|
||||||
|
expect(decodeJwtUsername('not-a-jwt')).toBeNull()
|
||||||
|
expect(decodeJwtUsername('only.two')).toBeNull()
|
||||||
|
expect(decodeJwtUsername('')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns null when the payload is not valid JSON', () => {
|
||||||
|
// base64url of the literal string `{not-json` — decodes fine, JSON.parse fails.
|
||||||
|
const badPayload = btoa('{not-json')
|
||||||
|
.replace(/\+/g, '-')
|
||||||
|
.replace(/\//g, '_')
|
||||||
|
.replace(/=+$/, '')
|
||||||
|
expect(decodeJwtUsername(`header.${badPayload}.sig`)).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns null for a payload that is not valid base64', () => {
|
||||||
|
// `!!!!` contains characters outside the base64url alphabet → atob throws.
|
||||||
|
expect(decodeJwtUsername('header.!!!!.sig')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handles a non-ASCII (UTF-8) username', () => {
|
||||||
|
const token = buildToken({ u: '管理员' })
|
||||||
|
expect(decodeJwtUsername(token)).toBe('管理员')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('also accepts standard base64 payload (with + and padding)', () => {
|
||||||
|
// Some encoders emit standard base64 rather than base64url; the decoder
|
||||||
|
// must tolerate both. btoa emits standard base64 directly.
|
||||||
|
const standardB64 = btoa(JSON.stringify({ u: 'admin' }))
|
||||||
|
expect(decodeJwtUsername(`header.${standardB64}.sig`)).toBe('admin')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,265 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import {
|
||||||
|
buildMessagesParams,
|
||||||
|
mergeMessages,
|
||||||
|
} from '@/utils/messages'
|
||||||
|
import type { ChatMessage, BeforeCursor } from '@/api/inquiry'
|
||||||
|
|
||||||
|
/** Build a minimal ChatMessage; only the sort/dedup-relevant fields vary. */
|
||||||
|
function msg(
|
||||||
|
overrides: Partial<ChatMessage> & Pick<ChatMessage, 'messageId' | 'sendTime'>,
|
||||||
|
): ChatMessage {
|
||||||
|
return {
|
||||||
|
inquiryId: 'I1',
|
||||||
|
sender: null,
|
||||||
|
direction: 'incoming',
|
||||||
|
content: '',
|
||||||
|
translation: null,
|
||||||
|
...overrides,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('buildMessagesParams', () => {
|
||||||
|
describe('both-or-neither cursor rule', () => {
|
||||||
|
it('returns NO cursor keys when no cursor is supplied', () => {
|
||||||
|
const params = buildMessagesParams(undefined)
|
||||||
|
expect(params).not.toHaveProperty('beforeSendTime')
|
||||||
|
expect(params).not.toHaveProperty('beforeMessageId')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns NO cursor keys when cursor is null', () => {
|
||||||
|
// prevCursor from the backend can be null at the top; passing that
|
||||||
|
// through must not leak a stray key.
|
||||||
|
const params = buildMessagesParams(null)
|
||||||
|
expect(params).not.toHaveProperty('beforeSendTime')
|
||||||
|
expect(params).not.toHaveProperty('beforeMessageId')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns BOTH cursor keys when a cursor is supplied', () => {
|
||||||
|
const cursor: BeforeCursor = {
|
||||||
|
beforeSendTime: '2026-07-14T10:00:00.000Z',
|
||||||
|
beforeMessageId: 'm-42',
|
||||||
|
}
|
||||||
|
const params = buildMessagesParams(cursor)
|
||||||
|
expect(params.beforeSendTime).toBe('2026-07-14T10:00:00.000Z')
|
||||||
|
expect(params.beforeMessageId).toBe('m-42')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('NEVER produces only one cursor key (the 400 gotcha)', () => {
|
||||||
|
// Drive every code path and assert the invariant holds — both keys are
|
||||||
|
// either present together or absent together. There is no API surface
|
||||||
|
// that lets a caller populate only one.
|
||||||
|
const cases: Array<BeforeCursor | null | undefined> = [
|
||||||
|
undefined,
|
||||||
|
null,
|
||||||
|
{ beforeSendTime: 'a', beforeMessageId: 'b' },
|
||||||
|
]
|
||||||
|
for (const c of cases) {
|
||||||
|
const params = buildMessagesParams(c)
|
||||||
|
const hasSend = 'beforeSendTime' in params
|
||||||
|
const hasId = 'beforeMessageId' in params
|
||||||
|
expect(hasSend).toBe(hasId)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('size', () => {
|
||||||
|
it('forwards a finite positive size', () => {
|
||||||
|
const params = buildMessagesParams(undefined, 50)
|
||||||
|
expect(params.size).toBe(50)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('omits size when not provided', () => {
|
||||||
|
const params = buildMessagesParams(undefined)
|
||||||
|
expect(params).not.toHaveProperty('size')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('omits size when undefined (even with cursor)', () => {
|
||||||
|
const params = buildMessagesParams(
|
||||||
|
{ beforeSendTime: 'a', beforeMessageId: 'b' },
|
||||||
|
undefined,
|
||||||
|
)
|
||||||
|
expect(params).not.toHaveProperty('size')
|
||||||
|
expect(params.beforeMessageId).toBe('b')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('omits size when 0 or negative (avoids ?size=0)', () => {
|
||||||
|
expect(buildMessagesParams(undefined, 0)).not.toHaveProperty('size')
|
||||||
|
expect(buildMessagesParams(undefined, -5)).not.toHaveProperty('size')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('omits size when NaN / non-finite', () => {
|
||||||
|
expect(buildMessagesParams(undefined, NaN)).not.toHaveProperty('size')
|
||||||
|
expect(buildMessagesParams(undefined, Infinity)).not.toHaveProperty('size')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns an empty object for the no-cursor / no-size first-page case', () => {
|
||||||
|
// Page 1 fetch: no cursor, default backend size. Locks the contract that
|
||||||
|
// page 1 sends a clean `?` (no params), matching the live E2E-TEST probe.
|
||||||
|
expect(buildMessagesParams()).toEqual({})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('mergeMessages', () => {
|
||||||
|
describe('dedup', () => {
|
||||||
|
it('drops a duplicate messageId (incoming wins)', () => {
|
||||||
|
const existing = [
|
||||||
|
msg({ messageId: 'm1', sendTime: '2026-07-14T10:00:00.000Z', content: 'old' }),
|
||||||
|
]
|
||||||
|
const incoming = [
|
||||||
|
msg({ messageId: 'm1', sendTime: '2026-07-14T10:00:00.000Z', content: 'new' }),
|
||||||
|
]
|
||||||
|
const merged = mergeMessages(existing, incoming)
|
||||||
|
expect(merged).toHaveLength(1)
|
||||||
|
expect(merged[0]?.content).toBe('new')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('dedups across many duplicates keeping last-write', () => {
|
||||||
|
const existing = [
|
||||||
|
msg({ messageId: 'a', sendTime: '2026-07-14T10:00:00.000Z' }),
|
||||||
|
msg({ messageId: 'b', sendTime: '2026-07-14T10:00:01.000Z' }),
|
||||||
|
]
|
||||||
|
const incoming = [
|
||||||
|
msg({ messageId: 'b', sendTime: '2026-07-14T10:00:01.000Z' }),
|
||||||
|
msg({ messageId: 'c', sendTime: '2026-07-14T10:00:02.000Z' }),
|
||||||
|
]
|
||||||
|
const merged = mergeMessages(existing, incoming)
|
||||||
|
expect(merged.map((m) => m.messageId)).toEqual(['a', 'b', 'c'])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('sort (ascending sendTime, then messageId)', () => {
|
||||||
|
it('sorts ascending by sendTime', () => {
|
||||||
|
const older = msg({ messageId: 'm1', sendTime: '2026-07-14T10:00:02.000Z' })
|
||||||
|
const newer = msg({ messageId: 'm2', sendTime: '2026-07-14T10:00:01.000Z' })
|
||||||
|
const merged = mergeMessages([older], [newer])
|
||||||
|
expect(merged.map((m) => m.messageId)).toEqual(['m2', 'm1'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('breaks ties on sendTime by messageId ascending', () => {
|
||||||
|
const sameTime = '2026-07-14T10:00:00.000Z'
|
||||||
|
// Pass them in reverse order to ensure it isn't just preserving input.
|
||||||
|
const merged = mergeMessages(
|
||||||
|
[msg({ messageId: 'c', sendTime: sameTime })],
|
||||||
|
[
|
||||||
|
msg({ messageId: 'b', sendTime: sameTime }),
|
||||||
|
msg({ messageId: 'a', sendTime: sameTime }),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
expect(merged.map((m) => m.messageId)).toEqual(['a', 'b', 'c'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('is stable when given an already-sorted page append', () => {
|
||||||
|
// The common case: existing = page 1 (older), incoming = page 2 (newer).
|
||||||
|
const existing = [
|
||||||
|
msg({ messageId: 'm1', sendTime: '2026-07-14T10:00:00.000Z' }),
|
||||||
|
msg({ messageId: 'm2', sendTime: '2026-07-14T10:00:01.000Z' }),
|
||||||
|
]
|
||||||
|
const incoming = [
|
||||||
|
msg({ messageId: 'm3', sendTime: '2026-07-14T10:00:02.000Z' }),
|
||||||
|
msg({ messageId: 'm4', sendTime: '2026-07-14T10:00:03.000Z' }),
|
||||||
|
]
|
||||||
|
const merged = mergeMessages(existing, incoming)
|
||||||
|
expect(merged.map((m) => m.messageId)).toEqual(['m1', 'm2', 'm3', 'm4'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('re-sorts an out-of-order incoming page correctly', () => {
|
||||||
|
// Defensive: if the backend (or a future join) ever returns an unsorted
|
||||||
|
// page, the merge still yields chronological order.
|
||||||
|
const existing: ChatMessage[] = []
|
||||||
|
const incoming = [
|
||||||
|
msg({ messageId: 'm3', sendTime: '2026-07-14T10:00:02.000Z' }),
|
||||||
|
msg({ messageId: 'm1', sendTime: '2026-07-14T10:00:00.000Z' }),
|
||||||
|
msg({ messageId: 'm2', sendTime: '2026-07-14T10:00:01.000Z' }),
|
||||||
|
]
|
||||||
|
const merged = mergeMessages(existing, incoming)
|
||||||
|
expect(merged.map((m) => m.messageId)).toEqual(['m1', 'm2', 'm3'])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('edge cases', () => {
|
||||||
|
it('returns [] for both empty', () => {
|
||||||
|
expect(mergeMessages([], [])).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns a copy of existing when incoming is empty (not the same ref)', () => {
|
||||||
|
const existing = [msg({ messageId: 'm1', sendTime: '2026-07-14T10:00:00.000Z' })]
|
||||||
|
const merged = mergeMessages(existing, [])
|
||||||
|
expect(merged).toEqual(existing)
|
||||||
|
expect(merged).not.toBe(existing)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns incoming-only when existing is empty', () => {
|
||||||
|
const incoming = [
|
||||||
|
msg({ messageId: 'm1', sendTime: '2026-07-14T10:00:00.000Z' }),
|
||||||
|
]
|
||||||
|
expect(mergeMessages([], incoming)).toEqual(incoming)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handles a single-element merge', () => {
|
||||||
|
const merged = mergeMessages(
|
||||||
|
[],
|
||||||
|
[msg({ messageId: 'only', sendTime: '2026-07-14T10:00:00.000Z' })],
|
||||||
|
)
|
||||||
|
expect(merged.map((m) => m.messageId)).toEqual(['only'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not mutate its inputs', () => {
|
||||||
|
const existing = [
|
||||||
|
msg({ messageId: 'm1', sendTime: '2026-07-14T10:00:01.000Z' }),
|
||||||
|
]
|
||||||
|
const incoming = [
|
||||||
|
msg({ messageId: 'm2', sendTime: '2026-07-14T10:00:00.000Z' }),
|
||||||
|
]
|
||||||
|
const existingSnap = existing.map((m) => ({ ...m }))
|
||||||
|
const incomingSnap = incoming.map((m) => ({ ...m }))
|
||||||
|
mergeMessages(existing, incoming)
|
||||||
|
expect(existing).toEqual(existingSnap)
|
||||||
|
expect(incoming).toEqual(incomingSnap)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('idempotent append (ChatList double-fetch guard)', () => {
|
||||||
|
// ChatList guards against double "加载更多" clicks (loadingMore flag +
|
||||||
|
// allLoaded check), but if a guard ever regressed and a duplicate page
|
||||||
|
// arrived, mergeMessages must absorb it without producing dup ids. This
|
||||||
|
// pins the dedup contract the UI relies on as a backstop.
|
||||||
|
|
||||||
|
it('merging the same page twice yields no duplicates and stable length', () => {
|
||||||
|
const pageA = [
|
||||||
|
msg({ messageId: 'a1', sendTime: '2026-07-14T10:00:00.000Z' }),
|
||||||
|
msg({ messageId: 'a2', sendTime: '2026-07-14T10:00:01.000Z' }),
|
||||||
|
]
|
||||||
|
// First append (e.g. page 1 → page 2).
|
||||||
|
let merged = mergeMessages([], pageA)
|
||||||
|
expect(merged).toHaveLength(2)
|
||||||
|
// Second append: the SAME items arrive again (a stale in-flight request
|
||||||
|
// resolves after the cursor already advanced).
|
||||||
|
merged = mergeMessages(merged, [...pageA])
|
||||||
|
expect(merged).toHaveLength(2)
|
||||||
|
// No id appears twice.
|
||||||
|
const ids = merged.map((m) => m.messageId)
|
||||||
|
expect(new Set(ids).size).toBe(ids.length)
|
||||||
|
// Order is still ascending (re-running sort didn't shuffle anything).
|
||||||
|
expect(merged.map((m) => m.messageId)).toEqual(['a1', 'a2'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('survives a chaotic sequence of overlapping appends', () => {
|
||||||
|
// Pathological sequence: page 1, then page 1 again, then page 2, then
|
||||||
|
// page 1+2 interleaved. The result is the union, deduped, sorted —
|
||||||
|
// never longer than the distinct set.
|
||||||
|
const page1 = [
|
||||||
|
msg({ messageId: 'p1', sendTime: '2026-07-14T10:00:00.000Z' }),
|
||||||
|
]
|
||||||
|
const page2 = [
|
||||||
|
msg({ messageId: 'p2', sendTime: '2026-07-14T10:00:01.000Z' }),
|
||||||
|
]
|
||||||
|
let merged = mergeMessages([], page1)
|
||||||
|
merged = mergeMessages(merged, page1)
|
||||||
|
merged = mergeMessages(merged, page2)
|
||||||
|
merged = mergeMessages(merged, [...page1, ...page2])
|
||||||
|
expect(merged.map((m) => m.messageId)).toEqual(['p1', 'p2'])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { currentMonthCount, sortMonthlyAsc } from '@/utils/stats'
|
||||||
|
import type { MonthStat } from '@/api/stats'
|
||||||
|
|
||||||
|
/** Build a MonthStat quickly. */
|
||||||
|
function m(ym: string, count: number): MonthStat {
|
||||||
|
return { ym, count }
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('currentMonthCount', () => {
|
||||||
|
describe('hit', () => {
|
||||||
|
it('returns the count for the matching ym', () => {
|
||||||
|
const monthly = [
|
||||||
|
m('2026-07', 5),
|
||||||
|
m('2026-06', 9),
|
||||||
|
m('2026-05', 2),
|
||||||
|
]
|
||||||
|
expect(currentMonthCount(monthly, '2026-07')).toBe(5)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('finds the matching ym regardless of position (not just [0])', () => {
|
||||||
|
// DESC newest-first means the current month is usually [0], but the
|
||||||
|
// helper must not assume that — a fresh month may sit further down or
|
||||||
|
// be absent entirely.
|
||||||
|
const monthly = [
|
||||||
|
m('2026-08', 1), // future month somehow present
|
||||||
|
m('2026-07', 4), // current month, mid-list
|
||||||
|
m('2026-06', 2),
|
||||||
|
]
|
||||||
|
expect(currentMonthCount(monthly, '2026-07')).toBe(4)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handles count === 0 (explicit zero-row in the list)', () => {
|
||||||
|
// Defensive: if the backend ever includes a 0-count row for the current
|
||||||
|
// month, we return that 0 (not "no data" 0) — same observable value but
|
||||||
|
// distinguishes "found, value 0" from "not found". Both surface as 0.
|
||||||
|
const monthly = [m('2026-07', 0)]
|
||||||
|
expect(currentMonthCount(monthly, '2026-07')).toBe(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('miss', () => {
|
||||||
|
it('returns 0 when ym is not in the list', () => {
|
||||||
|
// The common "new month, no inquiries yet" case: the backend omits the
|
||||||
|
// month entirely from the trailing 12-month window.
|
||||||
|
const monthly = [m('2026-06', 9), m('2026-05', 2)]
|
||||||
|
expect(currentMonthCount(monthly, '2026-07')).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns 0 for an empty list', () => {
|
||||||
|
// Empty DB case.
|
||||||
|
expect(currentMonthCount([], '2026-07')).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('with duplicate ym rows, returns the FIRST match (documents unspecified behavior)', () => {
|
||||||
|
// The backend should never emit duplicate `ym` rows, but if it ever did,
|
||||||
|
// `find` returns the first occurrence — pin that contract so a future
|
||||||
|
// refactor (e.g. switching to `reduce`/`filter`) doesn't silently change
|
||||||
|
// which row wins.
|
||||||
|
const monthly = [
|
||||||
|
m('2026-07', 5),
|
||||||
|
m('2026-07', 9), // duplicate
|
||||||
|
]
|
||||||
|
expect(currentMonthCount(monthly, '2026-07')).toBe(5)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('sortMonthlyAsc', () => {
|
||||||
|
it('flips a DESC list to ASC', () => {
|
||||||
|
// The exact shape the backend sends: trailing 12 months, newest first.
|
||||||
|
const desc = [
|
||||||
|
m('2026-07', 5),
|
||||||
|
m('2026-06', 9),
|
||||||
|
m('2026-05', 2),
|
||||||
|
m('2026-04', 7),
|
||||||
|
]
|
||||||
|
const asc = sortMonthlyAsc(desc)
|
||||||
|
expect(asc.map((x) => x.ym)).toEqual([
|
||||||
|
'2026-04',
|
||||||
|
'2026-05',
|
||||||
|
'2026-06',
|
||||||
|
'2026-07',
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps an already-ASC list ASC', () => {
|
||||||
|
const asc0 = [
|
||||||
|
m('2026-04', 7),
|
||||||
|
m('2026-05', 2),
|
||||||
|
m('2026-06', 9),
|
||||||
|
m('2026-07', 5),
|
||||||
|
]
|
||||||
|
expect(sortMonthlyAsc(asc0).map((x) => x.ym)).toEqual([
|
||||||
|
'2026-04',
|
||||||
|
'2026-05',
|
||||||
|
'2026-06',
|
||||||
|
'2026-07',
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handles out-of-order input (not strictly DESC)', () => {
|
||||||
|
// Defensive: if a future join/merge ever produced shuffled input, the
|
||||||
|
// helper still yields chronological order.
|
||||||
|
const shuffled = [
|
||||||
|
m('2026-05', 2),
|
||||||
|
m('2026-07', 5),
|
||||||
|
m('2026-04', 7),
|
||||||
|
m('2026-06', 9),
|
||||||
|
]
|
||||||
|
expect(sortMonthlyAsc(shuffled).map((x) => x.ym)).toEqual([
|
||||||
|
'2026-04',
|
||||||
|
'2026-05',
|
||||||
|
'2026-06',
|
||||||
|
'2026-07',
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('lexicographic YYYY-MM ordering is correct across years', () => {
|
||||||
|
// Zero-padded fixed-width YYYY-MM means string compare === chronological.
|
||||||
|
const mixed = [
|
||||||
|
m('2026-01', 1),
|
||||||
|
m('2025-12', 9),
|
||||||
|
m('2026-03', 2),
|
||||||
|
m('2025-11', 4),
|
||||||
|
]
|
||||||
|
expect(sortMonthlyAsc(mixed).map((x) => x.ym)).toEqual([
|
||||||
|
'2025-11',
|
||||||
|
'2025-12',
|
||||||
|
'2026-01',
|
||||||
|
'2026-03',
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not mutate the input array', () => {
|
||||||
|
const desc = [
|
||||||
|
m('2026-07', 5),
|
||||||
|
m('2026-06', 9),
|
||||||
|
m('2026-05', 2),
|
||||||
|
]
|
||||||
|
const snapshot = desc.map((x) => ({ ...x }))
|
||||||
|
sortMonthlyAsc(desc)
|
||||||
|
// Neither the array order nor the element shapes changed.
|
||||||
|
expect(desc).toEqual(snapshot)
|
||||||
|
// Specifically, the order is still DESC (the original was untouched).
|
||||||
|
expect(desc.map((x) => x.ym)).toEqual(['2026-07', '2026-06', '2026-05'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns a NEW array (not the same reference)', () => {
|
||||||
|
const asc0 = [
|
||||||
|
m('2026-05', 2),
|
||||||
|
m('2026-06', 9),
|
||||||
|
]
|
||||||
|
const sorted = sortMonthlyAsc(asc0)
|
||||||
|
expect(sorted).not.toBe(asc0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns a NEW array even for an empty input', () => {
|
||||||
|
const empty: MonthStat[] = []
|
||||||
|
const sorted = sortMonthlyAsc(empty)
|
||||||
|
expect(sorted).toEqual([])
|
||||||
|
expect(sorted).not.toBe(empty)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns [] for an empty input', () => {
|
||||||
|
expect(sortMonthlyAsc([])).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handles a single-element list', () => {
|
||||||
|
expect(sortMonthlyAsc([m('2026-07', 5)])).toEqual([m('2026-07', 5)])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('preserves the count payload on every entry', () => {
|
||||||
|
// Order test, but also assert the count values ride along correctly —
|
||||||
|
// guards against an accidental future refactor that drops fields.
|
||||||
|
const desc = [
|
||||||
|
m('2026-07', 5),
|
||||||
|
m('2026-06', 9),
|
||||||
|
]
|
||||||
|
const asc = sortMonthlyAsc(desc)
|
||||||
|
expect(asc).toEqual([
|
||||||
|
{ ym: '2026-06', count: 9 },
|
||||||
|
{ ym: '2026-07', count: 5 },
|
||||||
|
])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
/**
|
||||||
|
* Browser download helpers — pure filename parsing + DOM blob saver.
|
||||||
|
*
|
||||||
|
* Split out from `@/api/export` so the gnarly `Content-Disposition` parsing is
|
||||||
|
* unit-testable without a DOM, and so `downloadBlob` is reusable for any future
|
||||||
|
* blob download (PDF reports, etc.). One responsibility each:
|
||||||
|
* - {@link parseFilenameFromContentDisposition} — pure string → filename | null.
|
||||||
|
* - {@link downloadBlob} — DOM side-effect (object URL + temporary anchor).
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract the filename from a `Content-Disposition` header value.
|
||||||
|
*
|
||||||
|
* Matches the backend's `attachment; filename="inquiries.csv"` shape and the
|
||||||
|
* common variants in the wild:
|
||||||
|
* - quoted: `attachment; filename="inquiries.csv"`
|
||||||
|
* - unquoted: `attachment; filename=inquiries.csv`
|
||||||
|
* - extra params: `attachment; filename="x.csv"; size=123`
|
||||||
|
* - RFC 5987 `filename*`: `attachment; filename*=UTF-8''inquiries.csv`
|
||||||
|
* (decoded best-effort; falls back to the raw percent-decoded string when
|
||||||
|
* `decodeURIComponent` rejects malformed input).
|
||||||
|
*
|
||||||
|
* Case-insensitive on the `filename`/`filename*` token (per HTTP grammar).
|
||||||
|
* Returns `null` when:
|
||||||
|
* - the header is missing/empty
|
||||||
|
* - no `filename` (or `filename*`) directive is present
|
||||||
|
* - the captured value is empty or only whitespace
|
||||||
|
* - the value is empty after stripping the surrounding quotes
|
||||||
|
*
|
||||||
|
* NOT a full RFC 6266 parser — it deliberately ignores exotic edge cases
|
||||||
|
* (parameter continuations, escaped quotes inside quoted strings). The backend
|
||||||
|
* emits a fixed, simple shape; this handles that plus the common variants.
|
||||||
|
*/
|
||||||
|
export function parseFilenameFromContentDisposition(
|
||||||
|
header: string | undefined | null,
|
||||||
|
): string | null {
|
||||||
|
if (!header) return null
|
||||||
|
|
||||||
|
// RFC 5987 `filename*=charset'lang'value` wins over plain `filename` when
|
||||||
|
// both are present (the spec says clients SHOULD prefer filename*). Match
|
||||||
|
// case-insensitively; the value is percent-encoded (no quotes).
|
||||||
|
const star = header.match(/filename\*\s*=\s*[^']*''([^;]+)/i)
|
||||||
|
if (star?.[1]) {
|
||||||
|
const decoded = safeDecodeURIComponent(star[1])
|
||||||
|
const trimmed = decoded.trim()
|
||||||
|
if (trimmed) return trimmed
|
||||||
|
}
|
||||||
|
|
||||||
|
// Plain `filename=` — quoted or unquoted. The optional quotes are captured
|
||||||
|
// OUTSIDE the value group so we can strip them uniformly.
|
||||||
|
const plain = header.match(/filename\s*=\s*(?:"([^"]*)"|([^;]+))/i)
|
||||||
|
if (plain) {
|
||||||
|
// Group 1 = quoted value, group 2 = unquoted value; exactly one matches.
|
||||||
|
const raw = (plain[1] ?? plain[2] ?? '').trim()
|
||||||
|
if (raw) return raw
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `decodeURIComponent` that returns the input unchanged on malformed input
|
||||||
|
* (instead of throwing). Used for `filename*` values — a malformed percent-
|
||||||
|
* escape shouldn't crash the download; falling back to the raw string is a
|
||||||
|
* better UX than no filename.
|
||||||
|
*/
|
||||||
|
function safeDecodeURIComponent(s: string): string {
|
||||||
|
try {
|
||||||
|
return decodeURIComponent(s)
|
||||||
|
} catch {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trigger a browser download of `blob` as `filename` by synthesizing a
|
||||||
|
* temporary `<a download>` element and clicking it. Revokes the object URL
|
||||||
|
* immediately on the next tick to release the blob's memory.
|
||||||
|
*
|
||||||
|
* No unit test — pure DOM side-effect. Kept small + synchronous-after-click so
|
||||||
|
* callers can call it fire-and-forget from an async handler.
|
||||||
|
*/
|
||||||
|
export function downloadBlob(blob: Blob, filename: string): void {
|
||||||
|
// Wrap the DOM dance in try/finally so the object URL is ALWAYS revoked,
|
||||||
|
// even if `anchor.click()` (or any earlier line) throws — otherwise the
|
||||||
|
// blob's memory would leak for the lifetime of the page. The `url` guard
|
||||||
|
// covers the (theoretical) case where createObjectURL itself throws before
|
||||||
|
// assignment, leaving `url === ''`.
|
||||||
|
let url = ''
|
||||||
|
try {
|
||||||
|
url = URL.createObjectURL(blob)
|
||||||
|
const anchor = document.createElement('a')
|
||||||
|
anchor.href = url
|
||||||
|
anchor.download = filename
|
||||||
|
// `rel=noreferrer` keeps the synthetic navigation from leaking a Referer
|
||||||
|
// header to any (theoretical) redirect target; harmless for blob URLs.
|
||||||
|
anchor.rel = 'noreferrer'
|
||||||
|
// Append to body so Firefox fires the click download reliably (some
|
||||||
|
// browsers ignore detached-element clicks for downloads).
|
||||||
|
document.body.appendChild(anchor)
|
||||||
|
anchor.click()
|
||||||
|
anchor.remove()
|
||||||
|
} finally {
|
||||||
|
if (url) {
|
||||||
|
// Defer revoke one tick so the browser has actually started the
|
||||||
|
// download navigation; revoking synchronously can race with the click
|
||||||
|
// handler on some engines and produce an empty file.
|
||||||
|
setTimeout(() => URL.revokeObjectURL(url), 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import dayjs from 'dayjs'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared presentational helpers (DRY extraction, post-capstone cleanup).
|
||||||
|
*
|
||||||
|
* These three helpers were duplicated inline across views/components —
|
||||||
|
* `dash` had even diverged (BehaviorCard's version handled null slightly
|
||||||
|
* differently). Centralized here so there's ONE correct implementation.
|
||||||
|
* Pure presentational — behavior is identical to the inline originals.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render `—` for null / undefined / empty / whitespace-only strings so
|
||||||
|
* description cells and table columns don't show empty whitespace. Anything
|
||||||
|
* else (including `0`) is coerced to a string as-is.
|
||||||
|
*
|
||||||
|
* @param v any value; non-strings are stringified via `String(v)`.
|
||||||
|
*/
|
||||||
|
export function dash(v: unknown): string {
|
||||||
|
if (v == null) return '—'
|
||||||
|
if (typeof v === 'string' && v.trim() === '') return '—'
|
||||||
|
return String(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format an ISO timestamp to local-TZ `YYYY-MM-DD HH:mm` (spec §6). Returns
|
||||||
|
* `fallback` when `iso` is null/undefined or not a parseable date.
|
||||||
|
*
|
||||||
|
* @param iso ISO timestamp string (or null/undefined).
|
||||||
|
* @param fallback returned for null/undefined/invalid input (default `'—'`).
|
||||||
|
*/
|
||||||
|
export function formatTime(
|
||||||
|
iso: string | null | undefined,
|
||||||
|
fallback = '—',
|
||||||
|
): string {
|
||||||
|
if (!iso) return fallback
|
||||||
|
const d = dayjs(iso)
|
||||||
|
return d.isValid() ? d.format('YYYY-MM-DD HH:mm') : fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pull a human-readable message out of a caught error, falling back to
|
||||||
|
* `fallback` when there isn't one. Replaces the
|
||||||
|
* `e && typeof e === 'object' && 'message' in e ? String(...) : fallback`
|
||||||
|
* idiom that was repeated across ~5 catch blocks. A normalized
|
||||||
|
* {@link ApiError} (which always has a string `.message`) is returned as-is.
|
||||||
|
*
|
||||||
|
* @param e anything thrown.
|
||||||
|
* @param fallback returned when `e` has no string `message`.
|
||||||
|
*/
|
||||||
|
export function extractApiErrorMessage(e: unknown, fallback: string): string {
|
||||||
|
if (e && typeof e === 'object' && 'message' in e) {
|
||||||
|
const m = (e as { message: unknown }).message
|
||||||
|
if (typeof m === 'string') return m
|
||||||
|
}
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
import type { InquirySort, ListInquiriesParams } from '@/api/inquiry'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure filter-state → backend-query mapping for the inquiry list.
|
||||||
|
*
|
||||||
|
* Extracted from {@link InquiryList.vue} so the gnarly bits (especially the
|
||||||
|
* date→ISO conversion — see below) are unit-testable without mounting the
|
||||||
|
* component. The same module owns the {@link InquirySortOptions} menu metadata
|
||||||
|
* so the view and the param builder can't drift on the enum strings.
|
||||||
|
*
|
||||||
|
* CRITICAL — date→ISO conversion:
|
||||||
|
* The backend validates `dateFrom`/`dateTo` with `z.string().datetime()`, which
|
||||||
|
* REQUIRES a full ISO datetime (e.g. `2026-07-14T00:00:00.000Z`). A bare
|
||||||
|
* `YYYY-MM-DD` from `el-date-picker` (which yields Date objects anyway) would
|
||||||
|
* 400 if stringified naively. We always call `.toISOString()` so the wire
|
||||||
|
* format is correct regardless of the picker's configured `value-format`.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UI-facing sort menu. `value` is the backend enum string; `label` is what the
|
||||||
|
* el-select shows. Default selection is 最新创建 → `create_time_desc`.
|
||||||
|
*/
|
||||||
|
export const InquirySortOptions: ReadonlyArray<{ value: InquirySort; label: string }> =
|
||||||
|
[
|
||||||
|
{ value: 'updated_desc', label: '抓取时间' },
|
||||||
|
{ value: 'update_time_desc', label: '最近更新' },
|
||||||
|
{ value: 'create_time_desc', label: '最新创建' },
|
||||||
|
{ value: 'create_time_asc', label: '最早创建' },
|
||||||
|
]
|
||||||
|
|
||||||
|
/** Default `sort` selection when the user hasn't picked one.
|
||||||
|
* 匹配 defaultFilterState().sort,保持「初始=重置」一致(之前常量过时为 create_time_desc)。 */
|
||||||
|
export const DEFAULT_INQUIRY_SORT: InquirySort = 'update_time_desc'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reactive filter state shape used by InquiryList.vue. Kept here (not in the
|
||||||
|
* .vue) so the param builder's input type is stable and testable without
|
||||||
|
* pulling in Vue.
|
||||||
|
*
|
||||||
|
* `dateRange` mirrors what `el-date-picker type="daterange"` v-models: a
|
||||||
|
* 2-tuple of `Date | null`, or `null` when the field is cleared.
|
||||||
|
*/
|
||||||
|
export interface InquiryFilterState {
|
||||||
|
country: string
|
||||||
|
manager: string
|
||||||
|
status: string
|
||||||
|
q: string
|
||||||
|
dateRange: [Date | null, Date | null] | null
|
||||||
|
sort: InquirySort
|
||||||
|
page: number
|
||||||
|
size: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build the default (empty) filter state; useful for tests and 重置. */
|
||||||
|
export function defaultFilterState(
|
||||||
|
overrides: Partial<InquiryFilterState> = {},
|
||||||
|
): InquiryFilterState {
|
||||||
|
return {
|
||||||
|
country: '',
|
||||||
|
manager: '',
|
||||||
|
status: '',
|
||||||
|
q: '',
|
||||||
|
dateRange: null,
|
||||||
|
sort: 'update_time_desc',
|
||||||
|
page: 1,
|
||||||
|
size: 20,
|
||||||
|
...overrides,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert a {@link InquiryFilterState} into the params object passed to
|
||||||
|
* {@link listInquiries}.
|
||||||
|
*
|
||||||
|
* - `page`/`size` are coerced to numbers (the backend enum accepts ints; the
|
||||||
|
* inputs may be strings if a future caller binds them from the URL).
|
||||||
|
* - Falsy filters (empty country/manager/status/q) are omitted so they aren't
|
||||||
|
* serialized as `?country=` or `?country=undefined`.
|
||||||
|
* - `sort` is passed through verbatim (callers default it via
|
||||||
|
* {@link DEFAULT_INQUIRY_SORT}).
|
||||||
|
* - `dateRange` dates are converted via `.toISOString()`. Each side is added
|
||||||
|
* independently, so a half-empty range (which the picker normally won't
|
||||||
|
* produce, but a stale URL might) still sends only the set side.
|
||||||
|
*/
|
||||||
|
export function buildListParams(
|
||||||
|
state: InquiryFilterState,
|
||||||
|
): ListInquiriesParams {
|
||||||
|
const params: ListInquiriesParams = {
|
||||||
|
page: Number(state.page),
|
||||||
|
size: Number(state.size),
|
||||||
|
}
|
||||||
|
// Guard against NaN if a caller hands us a non-numeric string; drop the key
|
||||||
|
// rather than send `?page=NaN` (which would 400).
|
||||||
|
if (!Number.isFinite(params.page)) delete params.page
|
||||||
|
if (!Number.isFinite(params.size)) delete params.size
|
||||||
|
|
||||||
|
if (state.country) params.country = state.country
|
||||||
|
if (state.manager) params.manager = state.manager
|
||||||
|
if (state.status) params.status = state.status
|
||||||
|
if (state.q) params.q = state.q
|
||||||
|
if (state.sort) params.sort = state.sort
|
||||||
|
|
||||||
|
// `noUncheckedIndexedAccess` makes element access `T | undefined`; narrow
|
||||||
|
// with `!= null` so a `null` slot doesn't get stringified into "null".
|
||||||
|
const from = state.dateRange?.[0]
|
||||||
|
const to = state.dateRange?.[1]
|
||||||
|
if (from != null) params.dateFrom = from.toISOString()
|
||||||
|
if (to != null) params.dateTo = to.toISOString()
|
||||||
|
|
||||||
|
return params
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
/**
|
||||||
|
* Decode the username (`u` claim) from a JWT without verifying its signature.
|
||||||
|
*
|
||||||
|
* The backend login JWT payload is `{ sub, u, iat, exp }` where **`u`** is the
|
||||||
|
* username (e.g. `"admin"`). We only read this claim client-side for display;
|
||||||
|
* the backend remains the authority on every privileged decision, so signature
|
||||||
|
* verification is unnecessary here (a forged username in a stolen-token app is
|
||||||
|
* the least of our problems — see the TOKEN_KEY security TODO in stores/auth.ts).
|
||||||
|
*
|
||||||
|
* Robustness contract: any malformed input (wrong segment count, bad base64,
|
||||||
|
* non-JSON payload, missing/non-string `u`) returns `null` rather than throwing.
|
||||||
|
*
|
||||||
|
* NOTE: JWTs use base64url (`-_` instead of `+/`, no padding), so we convert
|
||||||
|
* back to standard base64 before handing off to `atob`. UTF-8 payloads are
|
||||||
|
* handled via TextDecoder (usernames could be non-ASCII).
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** JWT payload shape for the claims we care about. */
|
||||||
|
interface JwtPayload {
|
||||||
|
u?: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure helper: extract the `u` (username) claim from a JWT string.
|
||||||
|
* Returns the username, or `null` if the token is missing/malformed/has no `u`.
|
||||||
|
*/
|
||||||
|
export function decodeJwtUsername(token: string): string | null {
|
||||||
|
try {
|
||||||
|
const segments = token.split('.')
|
||||||
|
// We only need the payload segment (index 1), so `>= 2` is enough — a
|
||||||
|
// full JWT has three segments, but for a display-only reader we leniently
|
||||||
|
// accept anything with a decodable middle segment. `noUncheckedIndexedAccess`
|
||||||
|
// makes the element access `string | undefined`, so narrow explicitly.
|
||||||
|
const payloadSegment = segments.length >= 2 ? segments[1] : undefined
|
||||||
|
if (!payloadSegment) return null
|
||||||
|
|
||||||
|
// base64url → standard base64 (+ padding) for atob.
|
||||||
|
let b64 = payloadSegment.replace(/-/g, '+').replace(/_/g, '/')
|
||||||
|
const remainder = b64.length % 4
|
||||||
|
if (remainder) {
|
||||||
|
b64 += '='.repeat(4 - remainder)
|
||||||
|
}
|
||||||
|
|
||||||
|
// atob → binary string; reinterpret as UTF-8 bytes for non-ASCII usernames.
|
||||||
|
const binary = atob(b64)
|
||||||
|
const bytes = Uint8Array.from(binary, (c) => c.charCodeAt(0))
|
||||||
|
const json = new TextDecoder('utf-8').decode(bytes)
|
||||||
|
|
||||||
|
const payload = JSON.parse(json) as JwtPayload
|
||||||
|
return typeof payload.u === 'string' ? payload.u : null
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import type { ChatMessage, BeforeCursor } from '@/api/inquiry'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure helpers for the chat messages endpoint (`GET /inquiries/:id/messages`).
|
||||||
|
*
|
||||||
|
* Extracted from {@link ChatList.vue} so the two tricky invariants —
|
||||||
|
* (1) the cursor's both-or-neither rule and (2) dedup+sort when appending a
|
||||||
|
* new page to an existing list — are unit-testable without mounting the
|
||||||
|
* component or stubbing vue-virtual-scroller.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Query object handed to {@link listMessages}. Shaped to satisfy the backend's
|
||||||
|
* BOTH-OR-NEITHER cursor rule: `beforeSendTime` + `beforeMessageId` are present
|
||||||
|
* together when a `cursor` is supplied, and absent together when it isn't.
|
||||||
|
* `size` is optional on both paths.
|
||||||
|
*/
|
||||||
|
export interface MessagesQuery {
|
||||||
|
beforeSendTime?: string
|
||||||
|
beforeMessageId?: string
|
||||||
|
size?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the messages-endpoint query params from an optional before-cursor + size.
|
||||||
|
*
|
||||||
|
* The backend validates that `beforeSendTime` + `beforeMessageId` are EITHER
|
||||||
|
* both present OR both absent (else 400). This helper makes the both-present
|
||||||
|
* path the only way to send a cursor: pass the whole `cursor` object (set) or
|
||||||
|
* `undefined` (unset) — never a half-populated one — so the invariant can't be
|
||||||
|
* violated at a call site.
|
||||||
|
*
|
||||||
|
* `size` is forwarded only when explicitly provided (finite, > 0) so we don't
|
||||||
|
* send `?size=undefined` or `?size=0`.
|
||||||
|
*/
|
||||||
|
export function buildMessagesParams(
|
||||||
|
cursor?: BeforeCursor | null,
|
||||||
|
size?: number,
|
||||||
|
): MessagesQuery {
|
||||||
|
const params: MessagesQuery = {}
|
||||||
|
if (cursor) {
|
||||||
|
params.beforeSendTime = cursor.beforeSendTime
|
||||||
|
params.beforeMessageId = cursor.beforeMessageId
|
||||||
|
}
|
||||||
|
if (size != null && Number.isFinite(size) && size > 0) {
|
||||||
|
params.size = size
|
||||||
|
}
|
||||||
|
return params
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merge an existing message list with an incoming page, deduping by
|
||||||
|
* `messageId` and sorting **ascending** by `sendTime` then `messageId`
|
||||||
|
* (matches the backend's oldest-first ordering, so the merged list is stable
|
||||||
|
* regardless of which page arrived last — important for the cursor-append UX
|
||||||
|
* where a newer page is appended after an older one).
|
||||||
|
*
|
||||||
|
* When a duplicate `messageId` appears in both, the `incoming` copy wins
|
||||||
|
* (last-write-wins) — this is a defensive choice; in practice the cursor
|
||||||
|
* pagination shouldn't overlap, but if a re-fetch does return overlap, the
|
||||||
|
* newer fetch is assumed to be authoritative.
|
||||||
|
*
|
||||||
|
* Returns a new array; the inputs are not mutated.
|
||||||
|
*/
|
||||||
|
export function mergeMessages(
|
||||||
|
existing: ChatMessage[],
|
||||||
|
incoming: ChatMessage[],
|
||||||
|
): ChatMessage[] {
|
||||||
|
// Index `existing` by messageId; `incoming` overwrites on collision.
|
||||||
|
const byId = new Map<string, ChatMessage>()
|
||||||
|
for (const m of existing) byId.set(m.messageId, m)
|
||||||
|
for (const m of incoming) byId.set(m.messageId, m)
|
||||||
|
|
||||||
|
const merged = Array.from(byId.values())
|
||||||
|
// Ascending: oldest sendTime first; messageId is the stable tiebreaker for
|
||||||
|
// messages that share a sendTime (e.g. same-second bursts). Plain `.sort`
|
||||||
|
// is fine here — it's a fresh array, no aliasing concerns.
|
||||||
|
merged.sort((a, b) => {
|
||||||
|
if (a.sendTime !== b.sendTime) return a.sendTime < b.sendTime ? -1 : 1
|
||||||
|
if (a.messageId !== b.messageId) return a.messageId < b.messageId ? -1 : 1
|
||||||
|
return 0
|
||||||
|
})
|
||||||
|
return merged
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import type { MonthStat } from '@/api/stats'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure derived-stat helpers for the Stats page (Task 6).
|
||||||
|
*
|
||||||
|
* Extracted from {@link Stats.vue} so the two pieces of trivia that are easy
|
||||||
|
* to get wrong — (1) finding the current month's count in a DESC-ordered list
|
||||||
|
* and (2) flipping that list to chronological ASC for the line chart — are
|
||||||
|
* unit-testable without mounting the component or stubbing ECharts.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Look up the inquiry count for a given `YYYY-MM` month in the `monthly` list.
|
||||||
|
*
|
||||||
|
* Used by Stats.vue for the "本月新增" card: it passes
|
||||||
|
* `dayjs().format('YYYY-MM')` and we return the matching count, or `0` when
|
||||||
|
* there's no data for the current month yet (the backend omits zero-count
|
||||||
|
* months entirely, and a fresh month may not have appeared in the trailing
|
||||||
|
* 12-month window at all).
|
||||||
|
*
|
||||||
|
* The lookup is by value equality on `ym`, so input order (DESC vs ASC) is
|
||||||
|
* irrelevant. Returns `0` for an empty list — graceful fallback for an empty
|
||||||
|
* DB / new install.
|
||||||
|
*/
|
||||||
|
export function currentMonthCount(
|
||||||
|
monthly: readonly MonthStat[],
|
||||||
|
ym: string,
|
||||||
|
): number {
|
||||||
|
// `find` rather than indexing `[0]` — the current month is not guaranteed to
|
||||||
|
// be the first entry (DESC newest-first only covers months that have data,
|
||||||
|
// and a brand-new month may be ABSENT from the trailing 12-month window).
|
||||||
|
const hit = monthly.find((m) => m.ym === ym)
|
||||||
|
return hit?.count ?? 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return a new array of monthly stats sorted **ascending** by `ym`
|
||||||
|
* (chronological order — what the trend line chart needs).
|
||||||
|
*
|
||||||
|
* The backend returns `monthly` **DESC** (newest first). The line chart's X
|
||||||
|
* axis wants oldest → newest, so this flips the order. A fresh copy is always
|
||||||
|
* returned (the input is never mutated), even when the input is already ASC —
|
||||||
|
* callers can safely pass a `readonly` slice from the API response.
|
||||||
|
*
|
||||||
|
* String comparison on `YYYY-MM` is lexicographically correct (zero-padded
|
||||||
|
* fixed-width format), so no Date parsing is needed.
|
||||||
|
*/
|
||||||
|
export function sortMonthlyAsc(monthly: readonly MonthStat[]): MonthStat[] {
|
||||||
|
// Slice first so `.sort` cannot reorder the caller's array — important
|
||||||
|
// because `monthly` may be the live API response object.
|
||||||
|
return monthly.slice().sort((a, b) => {
|
||||||
|
if (a.ym !== b.ym) return a.ym < b.ym ? -1 : 1
|
||||||
|
return 0
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,341 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import axios from 'axios'
|
||||||
|
import { getInquiryDetail, type InquiryDetail } from '@/api/inquiry'
|
||||||
|
import { dash, extractApiErrorMessage, formatTime } from '@/utils/format'
|
||||||
|
import BehaviorCard from '@/components/BehaviorCard.vue'
|
||||||
|
import ChatList from '@/components/ChatList.vue'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 询盘详情 (Task 5).
|
||||||
|
*
|
||||||
|
* Layout: two cards on top (客户信息 + 行为数据) side by side via el-row/el-col
|
||||||
|
* (stacks on narrow viewports), then the ChatList (virtual-scroll) below. A
|
||||||
|
* back link to `/inquiries` is provided at the top.
|
||||||
|
*
|
||||||
|
* Fetches `getInquiryDetail(id)` on mount AND whenever `:id` changes. Vue
|
||||||
|
* Router does NOT remount a component when only a param changes (MainLayout's
|
||||||
|
* `<router-view/>` is unkeyed), so navigating `/inquiries/A → /inquiries/B`
|
||||||
|
* would otherwise leave the page showing A's customer/behavior/chat while the
|
||||||
|
* URL says B. The `watch(inquiryId)` here closes that hole; the `:key` on
|
||||||
|
* `<ChatList>` makes the child remount (re-run its page-1 fetch + abort the
|
||||||
|
* prior controller) for the same reason.
|
||||||
|
*
|
||||||
|
* A 404 surfaces a toast + an el-empty "未找到" fallback (rather than silently
|
||||||
|
* bouncing back, so the user sees why).
|
||||||
|
*
|
||||||
|
* Race / unmount: AbortController cancels the in-flight fetch on navigation.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
/** `route.params.id` is `string | string[]` — take the first segment. */
|
||||||
|
const inquiryId = computed(() => {
|
||||||
|
const id = route.params.id
|
||||||
|
return Array.isArray(id) ? (id[0] ?? '') : (id ?? '')
|
||||||
|
})
|
||||||
|
|
||||||
|
const detail = ref<InquiryDetail | null>(null)
|
||||||
|
const loading = ref(false)
|
||||||
|
/** Set when the fetch 404s (or otherwise fails to produce a detail) — drives
|
||||||
|
* the el-empty fallback so the user can see the inquiry wasn't found. */
|
||||||
|
const notFound = ref(false)
|
||||||
|
/** Set when the fetch fails with a NON-404 error (transient 500, network).
|
||||||
|
* Without this, a real failure would leave `notFound=false` + `detail=null`
|
||||||
|
* and the v-if/v-else-if chain renders nothing (just the fading toast). The
|
||||||
|
* loadError branch surfaces a retry button instead. */
|
||||||
|
const loadError = ref(false)
|
||||||
|
|
||||||
|
/** 该会话的所有询盘(「一会话多询盘」合并:allInquiries 记录该会话全部询盘号+创建时间) */
|
||||||
|
const allInquiries = computed<
|
||||||
|
Array<{ inquiryId: string; createTime?: string; status?: string | null; msgPreview?: string | null }>
|
||||||
|
>(() => {
|
||||||
|
const raw = detail.value?.allInquiries
|
||||||
|
if (!raw) return []
|
||||||
|
try {
|
||||||
|
return JSON.parse(raw)
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
let activeController: AbortController | null = null
|
||||||
|
|
||||||
|
async function fetchDetail() {
|
||||||
|
activeController?.abort()
|
||||||
|
const controller = new AbortController()
|
||||||
|
activeController = controller
|
||||||
|
|
||||||
|
loading.value = true
|
||||||
|
notFound.value = false
|
||||||
|
loadError.value = false
|
||||||
|
try {
|
||||||
|
detail.value = await getInquiryDetail(inquiryId.value, controller.signal)
|
||||||
|
} catch (e) {
|
||||||
|
if (axios.isCancel(e)) return
|
||||||
|
const status =
|
||||||
|
e && typeof e === 'object' && 'status' in e
|
||||||
|
? (e as { status: number }).status
|
||||||
|
: 0
|
||||||
|
const message = extractApiErrorMessage(e, '加载询盘详情失败,请稍后重试')
|
||||||
|
ElMessage.error({ message, grouping: true })
|
||||||
|
if (status === 404) notFound.value = true
|
||||||
|
else loadError.value = true
|
||||||
|
} finally {
|
||||||
|
if (activeController === controller) {
|
||||||
|
loading.value = false
|
||||||
|
activeController = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function goBack() {
|
||||||
|
// router.back() if there's history, else the inquiries list as a safe fall-
|
||||||
|
// back (e.g. user deep-linked to the detail URL).
|
||||||
|
if (window.history.length > 1) {
|
||||||
|
router.back()
|
||||||
|
} else {
|
||||||
|
void router.push('/inquiries')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
void fetchDetail()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Refetch on id change. Vue Router reuses this component instance across
|
||||||
|
// `/inquiries/A → /inquiries/B` (MainLayout's <router-view/> is unkeyed), so
|
||||||
|
// onMounted does NOT re-fire; without this watch, the page would silently keep
|
||||||
|
// showing the previous inquiry while the URL changes. Reset `detail` + error
|
||||||
|
// state so the stale cards / "未找到"/"加载失败" fallbacks don't flash while
|
||||||
|
// the new fetch is in flight. (`fetchDetail` also clears `notFound` +
|
||||||
|
// `loadError`, but reset here too so the view doesn't depend on fetch
|
||||||
|
// ordering for correctness.)
|
||||||
|
watch(inquiryId, (next, prev) => {
|
||||||
|
if (next && next !== prev) {
|
||||||
|
detail.value = null
|
||||||
|
notFound.value = false
|
||||||
|
loadError.value = false
|
||||||
|
void fetchDetail()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
activeController?.abort()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="inquiry-detail">
|
||||||
|
<div class="topbar">
|
||||||
|
<el-button link type="primary" @click="goBack">
|
||||||
|
← 返回列表
|
||||||
|
</el-button>
|
||||||
|
<h2 class="page-title">询盘详情</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Loading + not-found + load-error states wrap the whole content section
|
||||||
|
(not each card) so the layout doesn't flash half-populated. The back
|
||||||
|
button stays visible in every state so the user can navigate away.
|
||||||
|
Render chain order: loading → notFound → loadError → detail. The
|
||||||
|
loadError branch (transient 500 / network failure) gives the user a
|
||||||
|
retry path instead of a blank area once the toast fades.
|
||||||
|
-->
|
||||||
|
<div v-loading="loading">
|
||||||
|
<el-empty
|
||||||
|
v-if="notFound"
|
||||||
|
description="未找到该询盘"
|
||||||
|
>
|
||||||
|
<el-button type="primary" @click="goBack">返回列表</el-button>
|
||||||
|
</el-empty>
|
||||||
|
|
||||||
|
<el-empty
|
||||||
|
v-else-if="loadError"
|
||||||
|
description="加载失败,请重试"
|
||||||
|
>
|
||||||
|
<el-button type="primary" @click="fetchDetail">重试</el-button>
|
||||||
|
</el-empty>
|
||||||
|
|
||||||
|
<template v-else-if="detail">
|
||||||
|
<!-- Two cards on top: 客户信息 + 行为数据. -->
|
||||||
|
<el-row :gutter="16" class="cards-row">
|
||||||
|
<el-col :xs="24" :md="14">
|
||||||
|
<el-card shadow="never" class="info-card">
|
||||||
|
<template #header>
|
||||||
|
<span class="card-title">客户信息</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Customer fields first, then inquiry-level fields. el-descriptions
|
||||||
|
gives the labeled two-column layout for free. -->
|
||||||
|
<el-descriptions :column="2" border size="small">
|
||||||
|
<el-descriptions-item label="买家">
|
||||||
|
{{ dash(detail.customer?.name) }}
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="国家">
|
||||||
|
{{ dash(detail.customer?.country) }}
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="邮箱">
|
||||||
|
{{ dash(detail.customer?.email) }}
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="电话">
|
||||||
|
{{ dash(detail.customer?.phone) }}
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="座机">
|
||||||
|
{{ dash(detail.customer?.landline) }}
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="商业类型">
|
||||||
|
{{ dash(detail.customer?.businessType) }}
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="销售平台">
|
||||||
|
{{ dash(detail.customer?.salesPlatform) }}
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="公司官网">
|
||||||
|
{{ dash(detail.customer?.website) }}
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="公司">
|
||||||
|
{{ dash(detail.customer?.company) }}
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="注册时间">
|
||||||
|
{{ formatTime(detail.customer?.registerTime) }}
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="买家标签">
|
||||||
|
{{ dash(detail.customer?.buyerTags) }}
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="买家特征" :span="2">
|
||||||
|
{{ dash(detail.customer?.buyerFeatures) }}
|
||||||
|
</el-descriptions-item>
|
||||||
|
</el-descriptions>
|
||||||
|
|
||||||
|
<el-divider content-position="left">
|
||||||
|
<span class="divider-text">询盘信息</span>
|
||||||
|
</el-divider>
|
||||||
|
|
||||||
|
<el-descriptions :column="2" border size="small">
|
||||||
|
<el-descriptions-item label="询价单号">
|
||||||
|
{{ detail.inquiryId }}
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="业务员">
|
||||||
|
{{ dash(detail.manager) }}
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="状态">
|
||||||
|
{{ dash(detail.status) }}
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="创建时间">
|
||||||
|
{{ formatTime(detail.createTime) }}
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="消息数">
|
||||||
|
{{ detail._count.messages }}
|
||||||
|
</el-descriptions-item>
|
||||||
|
</el-descriptions>
|
||||||
|
|
||||||
|
<!-- 同会话多询盘:展示该会话所有询盘号 + 创建时间(不漏) -->
|
||||||
|
<div v-if="allInquiries.length > 1" class="all-inq">
|
||||||
|
<div class="all-inq-title">该会话所有询盘(共 {{ allInquiries.length }} 条)</div>
|
||||||
|
<div class="all-inq-list">
|
||||||
|
<div v-for="it in allInquiries" :key="it.inquiryId" class="all-inq-item">
|
||||||
|
<span class="all-inq-id">{{ it.inquiryId }}</span>
|
||||||
|
<span class="all-inq-time">{{ formatTime(it.createTime) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
</el-col>
|
||||||
|
|
||||||
|
<el-col :xs="24" :md="10">
|
||||||
|
<BehaviorCard :customer="detail.customer" />
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Chat section (virtual-scroll, cursor pagination). `:key="inquiryId"`
|
||||||
|
forces ChatList to REMOUNT on id change (Vue Router reuses the parent
|
||||||
|
InquiryDetail instance), so its onMounted page-1 fetch re-runs and
|
||||||
|
the prior AbortController is torn down via onBeforeUnmount — no stale
|
||||||
|
messages from inquiry A linger under inquiry B.
|
||||||
|
-->
|
||||||
|
<div class="chat-section">
|
||||||
|
<ChatList :key="inquiryId" :inquiry-id="inquiryId" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.inquiry-detail {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cards-row {
|
||||||
|
/* Tighten the gutter so the two cards visually align. */
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-title {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.divider-text {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.all-inq {
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
.all-inq-title {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
.all-inq-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
.all-inq-item {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
font-size: 13px;
|
||||||
|
padding: 3px 8px;
|
||||||
|
background: var(--el-fill-color-light);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
.all-inq-id {
|
||||||
|
font-family: monospace;
|
||||||
|
color: var(--el-text-color-primary);
|
||||||
|
}
|
||||||
|
.all-inq-time {
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* On narrow viewports (xs), the el-col stack — add a little breathing room
|
||||||
|
between the stacked cards. */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.cards-row .el-col + .el-col {
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-section {
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,466 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import axios from 'axios'
|
||||||
|
import { listInquiries, type Inquiry } from '@/api/inquiry'
|
||||||
|
import { exportInquiriesCsv } from '@/api/export'
|
||||||
|
import { downloadBlob } from '@/utils/download'
|
||||||
|
import { dash, extractApiErrorMessage, formatTime } from '@/utils/format'
|
||||||
|
import {
|
||||||
|
InquirySortOptions,
|
||||||
|
buildListParams,
|
||||||
|
defaultFilterState,
|
||||||
|
type InquiryFilterState,
|
||||||
|
} from '@/utils/inquiryParams'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 询盘列表 (Task 4).
|
||||||
|
*
|
||||||
|
* Top filter bar (country / manager / status / date-range / free-text q /
|
||||||
|
* sort) drives a paginated el-table. Row click navigates to
|
||||||
|
* `/inquiries/<inquiryId>` (the InquiryDetail view, Task 5).
|
||||||
|
*
|
||||||
|
* Fetch strategy: filters apply on 查询 button or Enter (text fields), sort
|
||||||
|
* applies immediately on change, pagination keeps filters and refetches. The
|
||||||
|
* heavy lifting (date→ISO, falsy-filter omission, page/size coercion) lives in
|
||||||
|
* the pure, tested {@link buildListParams} helper — keep this view thin.
|
||||||
|
*
|
||||||
|
* Race / unmount safety: each fetch is driven by a fresh AbortController; a
|
||||||
|
* newer fetch (or unmount) aborts the in-flight one, and AbortController
|
||||||
|
* rejections are caught + swallowed silently so they neither toast nor blank
|
||||||
|
* the table. Prevents an older slow response (e.g. page 5) from overwriting a
|
||||||
|
* newer one (page 3) after the user clicks again.
|
||||||
|
*
|
||||||
|
* Export (Task 7): a 导出 CSV button in the filter bar streams `/api/export`
|
||||||
|
* with the CURRENT filters (so the file matches what's on screen) via an axios
|
||||||
|
* blob fetch (JWT auto-attached). Uses its own AbortController so navigating
|
||||||
|
* away mid-download cancels cleanly instead of firing a stale 成功 toast after
|
||||||
|
* the view is gone.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `default-time` templates for the daterange picker. Only the time-of-day
|
||||||
|
* portion is used by Element Plus as the stamp for the picked calendar day:
|
||||||
|
* start side → 00:00:00, end side → 23:59:59.999. Without this, the picker
|
||||||
|
* emits two midnights, so `dateTo=YYYY-MM-DDT00:00:00.000Z` would EXCLUDE
|
||||||
|
* everything on the last day after midnight. Combined with `buildListParams`'s
|
||||||
|
* `.toISOString()`, the wire format for the end bound becomes
|
||||||
|
* `<last-day>T23:59:59.999Z` (local TZ), which is what the backend needs.
|
||||||
|
* Note the explicit 999 ms: `new Date(..., 23, 59, 59)` yields `.000Z` (ms=0),
|
||||||
|
* so the literal last millisecond of the day must be set explicitly.
|
||||||
|
*/
|
||||||
|
const DEFAULT_START_TIME = new Date(2000, 0, 1, 0, 0, 0)
|
||||||
|
const DEFAULT_END_TIME = new Date(2000, 0, 1, 23, 59, 59, 999)
|
||||||
|
|
||||||
|
/** 列表状态(页码+筛选)持久化到 sessionStorage:进详情返回后恢复到之前的页码/筛选。 */
|
||||||
|
const LIST_STATE_KEY = 'alibb_inquiry_list_state'
|
||||||
|
function loadListState(): InquiryFilterState {
|
||||||
|
try {
|
||||||
|
const raw = sessionStorage.getItem(LIST_STATE_KEY)
|
||||||
|
if (!raw) return defaultFilterState()
|
||||||
|
const s = JSON.parse(raw)
|
||||||
|
// sort 不从缓存恢复:列表默认始终按「更新时间」排序
|
||||||
|
const { sort: _ignoredSort, ...rest } = s
|
||||||
|
return defaultFilterState({
|
||||||
|
...rest,
|
||||||
|
// dateRange 的 Date 经 JSON 变成 ISO 字符串,这里还原成 Date
|
||||||
|
dateRange: Array.isArray(rest.dateRange) && rest.dateRange[0]
|
||||||
|
? [new Date(rest.dateRange[0]), new Date(rest.dateRange[1])]
|
||||||
|
: null,
|
||||||
|
})
|
||||||
|
} catch { return defaultFilterState() }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Live filter state — passed to buildListParams on every fetch. */
|
||||||
|
const filters = reactive<InquiryFilterState>(loadListState())
|
||||||
|
// 状态变化(翻页/筛选)写回 sessionStorage,返回列表时恢复
|
||||||
|
watch(filters, (f) => {
|
||||||
|
const dr = f.dateRange
|
||||||
|
sessionStorage.setItem(LIST_STATE_KEY, JSON.stringify({
|
||||||
|
country: f.country, manager: f.manager, status: f.status, q: f.q,
|
||||||
|
sort: f.sort, page: f.page, size: f.size,
|
||||||
|
dateRange: dr && dr[0] ? [dr[0].toISOString(), dr[1]?.toISOString() ?? null] : null,
|
||||||
|
}))
|
||||||
|
}, { deep: true })
|
||||||
|
const items = ref<Inquiry[]>([])
|
||||||
|
const total = ref(0)
|
||||||
|
const loading = ref(false)
|
||||||
|
/** True while a CSV export download is in flight (drives button :loading). */
|
||||||
|
const exporting = ref(false)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The AbortController for the currently in-flight fetch (if any). Each
|
||||||
|
* `fetchList` call aborts the previous one and replaces this. `null` when no
|
||||||
|
* request is pending. Captured in `onBeforeUnmount` so navigation away mid-
|
||||||
|
* fetch cancels cleanly (no spurious error toast after the view is gone).
|
||||||
|
*/
|
||||||
|
let activeController: AbortController | null = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The AbortController for the in-flight CSV export download (if any). Tracked
|
||||||
|
* separately from `activeController` so an export in flight doesn't get
|
||||||
|
* canceled by a list refetch (or vice versa) — they have independent lifetimes
|
||||||
|
* and only share the unmount-abort path. Aborted on unmount for the same
|
||||||
|
* reason as `activeController`: avoid a stale 成功 toast after navigation.
|
||||||
|
*/
|
||||||
|
let exportController: AbortController | null = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch the list using the current `filters`. Aborts any in-flight request
|
||||||
|
* first (race guard). On error, surface the normalized ApiError message but
|
||||||
|
* keep the previously-loaded rows so the UI doesn't blank out on a transient
|
||||||
|
* failure. Cancellations (from a newer fetch or unmount) are silent.
|
||||||
|
*/
|
||||||
|
async function fetchList() {
|
||||||
|
// Cancel any in-flight request so its (possibly stale) response can't
|
||||||
|
// overwrite the result of this newer fetch.
|
||||||
|
activeController?.abort()
|
||||||
|
const controller = new AbortController()
|
||||||
|
activeController = controller
|
||||||
|
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const res = await listInquiries(
|
||||||
|
buildListParams(filters),
|
||||||
|
controller.signal,
|
||||||
|
)
|
||||||
|
items.value = res.items
|
||||||
|
total.value = res.total
|
||||||
|
} catch (e) {
|
||||||
|
// Canceled by a newer fetch or by unmount — expected, stay silent. Must
|
||||||
|
// be checked BEFORE any toast/state so we don't surface a "请求失败" toast
|
||||||
|
// for an abort we initiated.
|
||||||
|
if (axios.isCancel(e)) return
|
||||||
|
const message = extractApiErrorMessage(e, '加载询盘列表失败,请稍后重试')
|
||||||
|
// grouping:true collapses repeated identical toasts (e.g. several failed
|
||||||
|
// retries in a row) into one, so the user isn't spammed.
|
||||||
|
ElMessage.error({ message, grouping: true })
|
||||||
|
} finally {
|
||||||
|
// Only clear the spinner if THIS controller is still the active one — a
|
||||||
|
// superseded request (abort → newer fetch already started) must not turn
|
||||||
|
// off the newer request's loading state.
|
||||||
|
if (activeController === controller) {
|
||||||
|
loading.value = false
|
||||||
|
activeController = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Apply the filter bar (text + date) — always restart from page 1. */
|
||||||
|
function applyFilters() {
|
||||||
|
filters.page = 1
|
||||||
|
void fetchList()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reset every filter to its default, then refetch. */
|
||||||
|
function resetFilters() {
|
||||||
|
// Object.assign onto the same reactive object so v-model bindings stay wired.
|
||||||
|
Object.assign(filters, defaultFilterState())
|
||||||
|
void fetchList()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Page-size dropdown changed: restart from page 1 with the new size. */
|
||||||
|
function onSizeChange() {
|
||||||
|
filters.page = 1
|
||||||
|
void fetchList()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sort select changed: restart from page 1 with the new ordering. */
|
||||||
|
function onSortChange() {
|
||||||
|
filters.page = 1
|
||||||
|
void fetchList()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Row click → detail. Uses `inquiryId` (business key) rather than the numeric
|
||||||
|
* `id`, matching how the backend resolves `/api/inquiries/:id` and how Task 5
|
||||||
|
* will resolve the detail route.
|
||||||
|
*/
|
||||||
|
function onRowClick(row: Inquiry) {
|
||||||
|
void router.push(`/inquiries/${row.inquiryId}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stream a CSV of the CURRENT filters down to the user's browser.
|
||||||
|
*
|
||||||
|
* - Uses {@link buildListParams}(filters) so the exported rows match what's on
|
||||||
|
* screen (same date→ISO, same filter omission). `page/size/sort` are sent
|
||||||
|
* but ignored by the export endpoint.
|
||||||
|
* - Re-entry guard (`if (exporting.value) return`) so a double-click during
|
||||||
|
* the download doesn't fire a second request and orphan the first
|
||||||
|
* controller. The button is also `:disabled="exporting"` as a UI guard.
|
||||||
|
* - On success: hand the blob to {@link downloadBlob} (object URL + temporary
|
||||||
|
* anchor + click) and surface a 成功 toast.
|
||||||
|
* - Cancellation (from unmount) is silent — same convention as `fetchList`.
|
||||||
|
* Real errors surface a generic message; with `responseType:'blob'`, the
|
||||||
|
* interceptor's server-message extraction won't find `{error,message}` in a
|
||||||
|
* non-2xx response body (it comes back as a Blob), so the normalized
|
||||||
|
* `ApiError.message` is already generic — fall back to 导出失败 on top.
|
||||||
|
*/
|
||||||
|
async function handleExport() {
|
||||||
|
if (exporting.value) return
|
||||||
|
exportController?.abort()
|
||||||
|
const controller = new AbortController()
|
||||||
|
exportController = controller
|
||||||
|
|
||||||
|
exporting.value = true
|
||||||
|
try {
|
||||||
|
const result = await exportInquiriesCsv(
|
||||||
|
buildListParams(filters),
|
||||||
|
controller.signal,
|
||||||
|
)
|
||||||
|
downloadBlob(result.blob, result.filename)
|
||||||
|
ElMessage.success('导出成功')
|
||||||
|
} catch (e) {
|
||||||
|
// Cancel from unmount — expected, stay silent (must be checked first).
|
||||||
|
if (axios.isCancel(e)) return
|
||||||
|
const message = extractApiErrorMessage(e, '导出失败,请稍后重试')
|
||||||
|
ElMessage.error({
|
||||||
|
message: message || '导出失败,请稍后重试',
|
||||||
|
grouping: true,
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
// Only release the spinner if THIS controller is still the active one —
|
||||||
|
// a superseded export (abort → newer export already started) must not
|
||||||
|
// clear the newer request's loading state.
|
||||||
|
if (exportController === controller) {
|
||||||
|
exporting.value = false
|
||||||
|
exportController = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
void fetchList()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Cancel any in-flight fetch (and any in-flight export) when the user
|
||||||
|
// navigates to detail (or away) so its rejection doesn't surface a toast
|
||||||
|
// after this view is gone.
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
activeController?.abort()
|
||||||
|
exportController?.abort()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="inquiry-list">
|
||||||
|
<el-form
|
||||||
|
:inline="true"
|
||||||
|
class="filter-bar"
|
||||||
|
@submit.prevent="applyFilters"
|
||||||
|
>
|
||||||
|
<el-form-item label="国家">
|
||||||
|
<el-input
|
||||||
|
v-model="filters.country"
|
||||||
|
placeholder="国家"
|
||||||
|
clearable
|
||||||
|
@keyup.enter="applyFilters"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item label="业务员">
|
||||||
|
<el-input
|
||||||
|
v-model="filters.manager"
|
||||||
|
placeholder="业务员"
|
||||||
|
clearable
|
||||||
|
@keyup.enter="applyFilters"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item label="状态">
|
||||||
|
<el-input
|
||||||
|
v-model="filters.status"
|
||||||
|
placeholder="状态"
|
||||||
|
clearable
|
||||||
|
@keyup.enter="applyFilters"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item label="时间范围">
|
||||||
|
<!--
|
||||||
|
No `value-format`: the model stays as `[Date, Date]` so
|
||||||
|
buildListParams can call `.toISOString()` (backend zod requires a
|
||||||
|
full datetime; a bare YYYY-MM-DD 400s).
|
||||||
|
`default-time` stamps the start day as 00:00:00 and the end day as
|
||||||
|
23:59:59 (local TZ), so the upper bound includes the whole last day
|
||||||
|
— without it, `dateTo=<last-day>T00:00:00.000Z` would drop everything
|
||||||
|
after midnight on that day.
|
||||||
|
-->
|
||||||
|
<el-date-picker
|
||||||
|
v-model="filters.dateRange"
|
||||||
|
type="daterange"
|
||||||
|
range-separator="至"
|
||||||
|
start-placeholder="开始日期"
|
||||||
|
end-placeholder="结束日期"
|
||||||
|
:default-time="[DEFAULT_START_TIME, DEFAULT_END_TIME]"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item label="搜索">
|
||||||
|
<el-input
|
||||||
|
v-model="filters.q"
|
||||||
|
placeholder="询价单号 / 买家 / 公司"
|
||||||
|
clearable
|
||||||
|
class="q-input"
|
||||||
|
@keyup.enter="applyFilters"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item label="排序">
|
||||||
|
<el-select v-model="filters.sort" @change="onSortChange">
|
||||||
|
<el-option
|
||||||
|
v-for="opt in InquirySortOptions"
|
||||||
|
:key="opt.value"
|
||||||
|
:label="opt.label"
|
||||||
|
:value="opt.value"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item>
|
||||||
|
<el-button type="primary" @click="applyFilters">查询</el-button>
|
||||||
|
<el-button @click="resetFilters">重置</el-button>
|
||||||
|
<!--
|
||||||
|
CSV export (Task 7). Streams /api/export with the CURRENT filters
|
||||||
|
via an axios blob fetch (JWT auto-attached). :loading shows the
|
||||||
|
spinner during the download; :disabled + the handler's re-entry
|
||||||
|
guard prevent a double-click from firing a second request. Handler
|
||||||
|
aborts cleanly on unmount — no stale 成功 toast after navigation.
|
||||||
|
-->
|
||||||
|
<el-button
|
||||||
|
type="success"
|
||||||
|
:loading="exporting"
|
||||||
|
:disabled="exporting"
|
||||||
|
@click="handleExport"
|
||||||
|
>
|
||||||
|
导出 CSV
|
||||||
|
</el-button>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
|
||||||
|
<el-table
|
||||||
|
v-loading="loading"
|
||||||
|
:data="items"
|
||||||
|
highlight-current-row
|
||||||
|
stripe
|
||||||
|
class="inquiry-table"
|
||||||
|
row-class-name="clickable-row"
|
||||||
|
empty-text="暂无数据"
|
||||||
|
@row-click="onRowClick"
|
||||||
|
>
|
||||||
|
<el-table-column prop="inquiryId" label="询价单号" min-width="160">
|
||||||
|
<template #default="{ row }: { row: Inquiry }">
|
||||||
|
<span class="inquiry-id-link">{{ row.inquiryId }}</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
|
||||||
|
<el-table-column label="买家" min-width="120">
|
||||||
|
<template #default="{ row }: { row: Inquiry }">
|
||||||
|
{{ dash(row.customer?.name) }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
|
||||||
|
<el-table-column label="邮箱" min-width="190">
|
||||||
|
<template #default="{ row }: { row: Inquiry }">
|
||||||
|
{{ dash(row.customer?.email) }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
|
||||||
|
<el-table-column label="电话" min-width="140">
|
||||||
|
<template #default="{ row }: { row: Inquiry }">
|
||||||
|
{{ dash(row.customer?.phone) }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
|
||||||
|
<el-table-column label="国家" min-width="90">
|
||||||
|
<template #default="{ row }: { row: Inquiry }">
|
||||||
|
{{ dash(row.customer?.country) }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
|
||||||
|
<el-table-column label="业务员" min-width="110">
|
||||||
|
<template #default="{ row }: { row: Inquiry }">
|
||||||
|
{{ dash(row.manager) }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
|
||||||
|
<el-table-column label="状态" min-width="100">
|
||||||
|
<template #default="{ row }: { row: Inquiry }">
|
||||||
|
{{ dash(row.status) }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
|
||||||
|
<el-table-column label="创建时间" min-width="150">
|
||||||
|
<template #default="{ row }: { row: Inquiry }">
|
||||||
|
{{ formatTime(row.createTime) }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
|
||||||
|
<el-table-column label="更新时间" min-width="150">
|
||||||
|
<template #default="{ row }: { row: Inquiry }">
|
||||||
|
{{ formatTime(row.updateTime) }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
|
||||||
|
<el-table-column label="抓取时间" min-width="150">
|
||||||
|
<template #default="{ row }: { row: Inquiry }">
|
||||||
|
{{ formatTime(row.updatedAt) }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
|
||||||
|
<template #empty>
|
||||||
|
<span>{{ loading ? '加载中…' : '暂无数据' }}</span>
|
||||||
|
</template>
|
||||||
|
</el-table>
|
||||||
|
|
||||||
|
<el-pagination
|
||||||
|
v-model:current-page="filters.page"
|
||||||
|
v-model:page-size="filters.size"
|
||||||
|
class="pagination"
|
||||||
|
:total="total"
|
||||||
|
:page-sizes="[10, 20, 50, 100]"
|
||||||
|
layout="total, sizes, prev, pager, next, jumper"
|
||||||
|
background
|
||||||
|
@current-change="fetchList"
|
||||||
|
@size-change="onSizeChange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.inquiry-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* el-form inline items wrap nicely on narrow viewports. */
|
||||||
|
.filter-bar {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
row-gap: 8px;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.q-input {
|
||||||
|
width: 240px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Make the whole row feel clickable (row-click navigates to detail). */
|
||||||
|
.inquiry-table :deep(.clickable-row) {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Visual affordance that 询价单号 is the click target. */
|
||||||
|
.inquiry-id-link {
|
||||||
|
color: var(--el-color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination {
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { reactive, ref } from 'vue'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import type { FormInstance, FormRules } from 'element-plus'
|
||||||
|
import { loginApi } from '@/api/auth'
|
||||||
|
import { extractApiErrorMessage } from '@/utils/format'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
const auth = useAuthStore()
|
||||||
|
|
||||||
|
const formRef = ref<FormInstance>()
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
|
const form = reactive({
|
||||||
|
username: '',
|
||||||
|
password: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const rules: FormRules<typeof form> = {
|
||||||
|
username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
|
||||||
|
password: [{ required: true, message: '请输入密码', trigger: 'blur' }],
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pick the post-login destination from the `?redirect=` query set by the
|
||||||
|
* router guard, with open-redirect protection: only same-origin absolute
|
||||||
|
* paths (leading "/", no protocol-relative "//"), and never /login itself.
|
||||||
|
* Falls back to /inquiries.
|
||||||
|
*/
|
||||||
|
function safeRedirect(): string {
|
||||||
|
const r = typeof route.query.redirect === 'string' ? route.query.redirect : ''
|
||||||
|
return r.startsWith('/') && !r.startsWith('//') && r !== '/login'
|
||||||
|
? r
|
||||||
|
: '/inquiries'
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit() {
|
||||||
|
// Guard against double-submit: @keyup.enter and @submit.prevent can both
|
||||||
|
// fire for the same keystroke, and :disabled only blocks pointer clicks.
|
||||||
|
if (loading.value) return
|
||||||
|
|
||||||
|
const valid = await formRef.value?.validate().catch(() => false)
|
||||||
|
if (!valid) return
|
||||||
|
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const { token } = await loginApi(form.username, form.password)
|
||||||
|
auth.setToken(token)
|
||||||
|
// replace() so the login page is not kept in history (back button skips it)
|
||||||
|
await router.replace(safeRedirect())
|
||||||
|
} catch (e) {
|
||||||
|
const message = extractApiErrorMessage(e, '登录失败,请稍后重试')
|
||||||
|
ElMessage.error(message)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="login-page">
|
||||||
|
<el-card class="login-card" shadow="always">
|
||||||
|
<template #header>
|
||||||
|
<div class="login-title">Alibb 管理后台</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<el-form
|
||||||
|
ref="formRef"
|
||||||
|
:model="form"
|
||||||
|
:rules="rules"
|
||||||
|
label-position="top"
|
||||||
|
@submit.prevent="handleSubmit"
|
||||||
|
>
|
||||||
|
<el-form-item label="用户名" prop="username">
|
||||||
|
<el-input
|
||||||
|
v-model="form.username"
|
||||||
|
placeholder="请输入用户名"
|
||||||
|
autocomplete="username"
|
||||||
|
clearable
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item label="密码" prop="password">
|
||||||
|
<el-input
|
||||||
|
v-model="form.password"
|
||||||
|
type="password"
|
||||||
|
placeholder="请输入密码"
|
||||||
|
autocomplete="current-password"
|
||||||
|
show-password
|
||||||
|
@keyup.enter="handleSubmit"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item>
|
||||||
|
<el-button
|
||||||
|
type="primary"
|
||||||
|
:loading="loading"
|
||||||
|
:disabled="loading"
|
||||||
|
class="login-submit"
|
||||||
|
@click="handleSubmit"
|
||||||
|
>
|
||||||
|
登录
|
||||||
|
</el-button>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
</el-card>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.login-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: #f5f7fa;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 380px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-title {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-submit {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,307 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import axios from 'axios'
|
||||||
|
import dayjs from 'dayjs'
|
||||||
|
import type { EChartsOption } from 'echarts'
|
||||||
|
// Side-effect: importing the full 'echarts' bundle registers every chart +
|
||||||
|
// component (pie/bar/line + tooltip/legend/grid/...). Acceptable for an admin
|
||||||
|
// tool — see the perf TODO in main.ts about tree-shaking later. Doing it here,
|
||||||
|
// locally to Stats.vue, keeps the import next to its only consumer.
|
||||||
|
import 'echarts'
|
||||||
|
// VChart is vue-echarts v8's default export — a single Vue component wrapping
|
||||||
|
// an echarts instance. Local import (no global app.component registration) so
|
||||||
|
// the bundle stays scoped to the stats route (lazy-loaded).
|
||||||
|
import VChart from 'vue-echarts'
|
||||||
|
import { getStatsOverview, type StatsOverview } from '@/api/stats'
|
||||||
|
import { extractApiErrorMessage } from '@/utils/format'
|
||||||
|
import { currentMonthCount, sortMonthlyAsc } from '@/utils/stats'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 统计 Stats (Task 6).
|
||||||
|
*
|
||||||
|
* Layout: two 数字卡片 (总数 / 本月新增) on top, then three ECharts cards in a
|
||||||
|
* responsive grid — 国家 Top10 pie / 业务员 bar / 月趋势 line. All four data
|
||||||
|
* sources come from one `GET /api/stats/overview` call.
|
||||||
|
*
|
||||||
|
* ECharts wiring (v8): `<v-chart :option="..." autoresize />` per chart, with
|
||||||
|
* each `option` built as a `computed` from the fetched data so charts react to
|
||||||
|
* refetches (e.g. future "refresh" button). `autoresize` makes them follow
|
||||||
|
* their fixed-height wrapper div on sidebar collapse / window resize.
|
||||||
|
*
|
||||||
|
* Empty data: when a chart's source array is empty, we render the chart with
|
||||||
|
* an empty series AND overlay an `el-empty` "暂无数据" placeholder. This is
|
||||||
|
* consistent across all three charts and matches how an empty DB surfaces
|
||||||
|
* (backend returns `total:0` + all `[]`).
|
||||||
|
*
|
||||||
|
* Race / unmount: AbortController cancels the in-flight fetch on navigation.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const overview = ref<StatsOverview | null>(null)
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
|
/** The current month as `YYYY-MM` — captured ONCE at mount so the "本月新增"
|
||||||
|
* card doesn't flicker if the calendar rolls over while the page is open. */
|
||||||
|
const currentYm = dayjs().format('YYYY-MM')
|
||||||
|
|
||||||
|
const total = computed(() => overview.value?.total ?? 0)
|
||||||
|
const monthNew = computed(() =>
|
||||||
|
overview.value ? currentMonthCount(overview.value.monthly, currentYm) : 0,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** True when there's no data yet at all (empty DB / first fetch). Drives the
|
||||||
|
* page-level empty-state copy under the cards. */
|
||||||
|
const isEmpty = computed(() => {
|
||||||
|
if (!overview.value) return false
|
||||||
|
return (
|
||||||
|
overview.value.total === 0 &&
|
||||||
|
overview.value.countryTop10.length === 0 &&
|
||||||
|
overview.value.byManager.length === 0 &&
|
||||||
|
overview.value.monthly.length === 0
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Per-chart empty flags so each card overlays its own "暂无数据" independently
|
||||||
|
// (one chart may be empty while others have data — e.g. all inquiries are from
|
||||||
|
// distinct countries so Top10 still has rows but no single country dominates).
|
||||||
|
const countryEmpty = computed(() => (overview.value?.countryTop10.length ?? 0) === 0)
|
||||||
|
const managerEmpty = computed(() => (overview.value?.byManager.length ?? 0) === 0)
|
||||||
|
const monthlyEmpty = computed(() => (overview.value?.monthly.length ?? 0) === 0)
|
||||||
|
|
||||||
|
/** Pie chart option — 国家 Top10. Backend already returns the top 10 DESC, so
|
||||||
|
* we plot as-is (pie has no inherent axis order). */
|
||||||
|
const countryOption = computed<EChartsOption>(() => {
|
||||||
|
const rows = overview.value?.countryTop10 ?? []
|
||||||
|
return {
|
||||||
|
tooltip: { trigger: 'item', formatter: '{b}: {c} ({d}%)' },
|
||||||
|
legend: { type: 'scroll', orient: 'vertical', left: 'left', top: 'middle' },
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
name: '国家',
|
||||||
|
type: 'pie',
|
||||||
|
radius: ['35%', '65%'],
|
||||||
|
center: ['60%', '50%'],
|
||||||
|
avoidLabelOverlap: true,
|
||||||
|
itemStyle: { borderRadius: 4, borderColor: '#fff', borderWidth: 2 },
|
||||||
|
label: { show: true, formatter: '{b}: {d}%' },
|
||||||
|
data: rows.map((r) => ({ name: r.country, value: r.count })),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
/** Bar chart option — 业务员 inquiry count. Rotate x-labels when there are
|
||||||
|
* many managers so they don't collide; threshold is conservative (>=6). */
|
||||||
|
const managerOption = computed<EChartsOption>(() => {
|
||||||
|
const rows = overview.value?.byManager ?? []
|
||||||
|
const many = rows.length >= 6
|
||||||
|
return {
|
||||||
|
tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } },
|
||||||
|
grid: { left: 40, right: 24, top: 24, bottom: many ? 64 : 40, containLabel: true },
|
||||||
|
xAxis: {
|
||||||
|
type: 'category',
|
||||||
|
data: rows.map((r) => r.manager),
|
||||||
|
axisLabel: { rotate: many ? 35 : 0, interval: 0 },
|
||||||
|
},
|
||||||
|
yAxis: { type: 'value', minInterval: 1 },
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
name: '询盘数',
|
||||||
|
type: 'bar',
|
||||||
|
itemStyle: { borderRadius: [4, 4, 0, 0] },
|
||||||
|
data: rows.map((r) => r.count),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
/** Line chart option — 月趋势. Backend returns monthly DESC; flip to ASC for
|
||||||
|
* chronological X axis via sortMonthlyAsc. */
|
||||||
|
const monthlyOption = computed<EChartsOption>(() => {
|
||||||
|
const asc = sortMonthlyAsc(overview.value?.monthly ?? [])
|
||||||
|
return {
|
||||||
|
tooltip: { trigger: 'axis' },
|
||||||
|
grid: { left: 40, right: 24, top: 24, bottom: 40, containLabel: true },
|
||||||
|
xAxis: { type: 'category', data: asc.map((x) => x.ym), boundaryGap: false },
|
||||||
|
yAxis: { type: 'value', minInterval: 1 },
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
name: '询盘数',
|
||||||
|
type: 'line',
|
||||||
|
smooth: true,
|
||||||
|
showSymbol: true,
|
||||||
|
areaStyle: { opacity: 0.08 },
|
||||||
|
data: asc.map((x) => x.count),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
let activeController: AbortController | null = null
|
||||||
|
|
||||||
|
async function fetchOverview() {
|
||||||
|
activeController?.abort()
|
||||||
|
const controller = new AbortController()
|
||||||
|
activeController = controller
|
||||||
|
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
overview.value = await getStatsOverview(controller.signal)
|
||||||
|
} catch (e) {
|
||||||
|
// Canceled by unmount or a newer fetch — silent (see request.ts note).
|
||||||
|
if (axios.isCancel(e)) return
|
||||||
|
const message = extractApiErrorMessage(e, '加载统计数据失败,请稍后重试')
|
||||||
|
// grouping:true collapses repeated identical toasts (e.g. several failed
|
||||||
|
// retries in a row) into one, so the user isn't spammed.
|
||||||
|
ElMessage.error({ message, grouping: true })
|
||||||
|
} finally {
|
||||||
|
if (activeController === controller) {
|
||||||
|
loading.value = false
|
||||||
|
activeController = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
void fetchOverview()
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
activeController?.abort()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="stats-page" v-loading="loading">
|
||||||
|
<!-- Top: 数字卡片. el-statistic gives the big-number layout for free. -->
|
||||||
|
<el-row :gutter="16" class="cards-row">
|
||||||
|
<el-col :xs="24" :sm="12">
|
||||||
|
<el-card shadow="never" class="stat-card">
|
||||||
|
<el-statistic title="询盘总数" :value="total" />
|
||||||
|
</el-card>
|
||||||
|
</el-col>
|
||||||
|
<el-col :xs="24" :sm="12">
|
||||||
|
<el-card shadow="never" class="stat-card">
|
||||||
|
<el-statistic :title="`本月新增(${currentYm})`" :value="monthNew" />
|
||||||
|
</el-card>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
|
||||||
|
<!-- Page-level empty hint (truly empty DB). Per-chart empty overlays
|
||||||
|
still appear below so the structure is visible. -->
|
||||||
|
<el-alert
|
||||||
|
v-if="!loading && isEmpty"
|
||||||
|
type="info"
|
||||||
|
:closable="false"
|
||||||
|
title="暂无统计数据"
|
||||||
|
description="尚未有询盘数据,待有数据后将在此展示统计图表。"
|
||||||
|
show-icon
|
||||||
|
class="empty-alert"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Charts: pie (Top10 国家) spans full width on its own row; bar (业务员)
|
||||||
|
+ line (月趋势) share the next row. el-row/el-col stack on xs. -->
|
||||||
|
<el-card shadow="never" class="chart-card">
|
||||||
|
<template #header><span class="card-title">国家 Top10</span></template>
|
||||||
|
<div class="chart-wrap">
|
||||||
|
<v-chart class="chart" :option="countryOption" autoresize />
|
||||||
|
<el-empty
|
||||||
|
v-if="countryEmpty"
|
||||||
|
description="暂无数据"
|
||||||
|
:image-size="80"
|
||||||
|
class="chart-empty"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<el-row :gutter="16" class="cards-row">
|
||||||
|
<el-col :xs="24" :lg="12">
|
||||||
|
<el-card shadow="never" class="chart-card">
|
||||||
|
<template #header><span class="card-title">业务员询盘数</span></template>
|
||||||
|
<div class="chart-wrap">
|
||||||
|
<v-chart class="chart" :option="managerOption" autoresize />
|
||||||
|
<el-empty
|
||||||
|
v-if="managerEmpty"
|
||||||
|
description="暂无数据"
|
||||||
|
:image-size="80"
|
||||||
|
class="chart-empty"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
</el-col>
|
||||||
|
|
||||||
|
<el-col :xs="24" :lg="12">
|
||||||
|
<el-card shadow="never" class="chart-card">
|
||||||
|
<template #header><span class="card-title">月趋势(近 12 个月)</span></template>
|
||||||
|
<div class="chart-wrap">
|
||||||
|
<v-chart class="chart" :option="monthlyOption" autoresize />
|
||||||
|
<el-empty
|
||||||
|
v-if="monthlyEmpty"
|
||||||
|
description="暂无数据"
|
||||||
|
:image-size="80"
|
||||||
|
class="chart-empty"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.stats-page {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cards-row {
|
||||||
|
/* Tighten gutter so cards visually align. */
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card,
|
||||||
|
.chart-card {
|
||||||
|
/* Subtle border, no shadow — same restraint as InquiryList/Detail. */
|
||||||
|
border: 1px solid var(--el-border-color-lighter);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-title {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-alert {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Fixed-height wrapper so v-chart's autoresize has a concrete box to fill.
|
||||||
|
el-empty sits absolutely-centered on top of the chart when data is empty. */
|
||||||
|
.chart-wrap {
|
||||||
|
position: relative;
|
||||||
|
height: 340px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-empty {
|
||||||
|
/* Overlay centered on top of the (empty) chart. */
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* On narrow viewports (xs), the el-col stack — add breathing room between
|
||||||
|
stacked cards (mirrors InquiryDetail). */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.cards-row .el-col + .el-col {
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
||||||
|
"include": ["env.d.ts", "src/**/*", "src/**/*.vue"],
|
||||||
|
"exclude": ["src/**/__tests__/*"],
|
||||||
|
"compilerOptions": {
|
||||||
|
// Extra safety for array and object lookups, but may have false positives.
|
||||||
|
"noUncheckedIndexedAccess": true,
|
||||||
|
|
||||||
|
// Path mapping for cleaner imports.
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
},
|
||||||
|
|
||||||
|
// `vue-tsc --build` produces a .tsbuildinfo file for incremental type-checking.
|
||||||
|
// Specified here to keep it out of the root directory.
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo"
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user