This commit is contained in:
浅念
2026-08-27 16:11:49 +08:00
commit 2d8ef3d7bb
104 changed files with 20196 additions and 0 deletions
+26
View File
@@ -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 的 ! 例外失效
+13
View File
@@ -0,0 +1,13 @@
node_modules
dist
.env
.env.*
*.log
.git
.gitignore
tests
vitest.config.ts
README.md
Dockerfile
docker-compose.yml
.dockerignore
+8
View File
@@ -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
+25
View File
@@ -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"]
+62
View File
@@ -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: {}
+5723
View File
File diff suppressed because it is too large Load Diff
+47
View File
@@ -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"
+99
View File
@@ -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")
}
+8
View File
@@ -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"
+18
View File
@@ -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"
+46
View File
@@ -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;
}
+32
View File
@@ -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],
});
+15
View File
@@ -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;
}
}
+14
View File
@@ -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 });
};
+14
View File
@@ -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();
};
+14
View File
@@ -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' });
}
};
+8
View File
@@ -0,0 +1,8 @@
import rateLimit from 'express-rate-limit';
export const ingestLimiter = rateLimit({
windowMs: 60_000,
max: 60,
standardHeaders: true,
legacyHeaders: false,
});
+33
View File
@@ -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,
});
});
+59
View File
@@ -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);
}
});
+85
View File
@@ -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);
}
});
+109
View File
@@ -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);
}
});
+13
View File
@@ -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);
}
});
+26
View File
@@ -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();
+30
View File
@@ -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,
};
}
+120
View File
@@ -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;
}
+110
View File
@@ -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,
};
}
+36
View File
@@ -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) })),
};
}
+26
View File
@@ -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 };
}
+6
View File
@@ -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;
}
+2
View File
@@ -0,0 +1,2 @@
export const maxSendTime = (msgs: { sendTime: string }[]) =>
msgs.length ? new Date(Math.max(...msgs.map(m => Date.parse(m.sendTime)))) : null;
+29
View File
@@ -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);
});
});
+54
View File
@@ -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'); // 含聊天原文
});
});
+45
View File
@@ -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: '',
},
],
};
}
+12
View File
@@ -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');
});
});
+73
View File
@@ -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);
});
});
+152
View File
@@ -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);
});
});
+41
View File
@@ -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;
}
+58
View File
@@ -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);
});
});
+15
View File
@@ -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/**/*"]
}
+10
View File
@@ -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 } },
},
});
+33
View File
@@ -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
+8
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
* text=auto eol=lf
+10
View File
@@ -0,0 +1,10 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["eslint", "typescript", "unicorn", "oxc", "vue"],
"env": {
"browser": true
},
"categories": {
"correctness": "error"
}
}
+8
View File
@@ -0,0 +1,8 @@
{
"recommendations": [
"Vue.volar",
"dbaeumer.vscode-eslint",
"EditorConfig.EditorConfig",
"oxc.oxc-vscode"
]
}
+11
View File
@@ -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"
}
}
+34
View File
@@ -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;"]
+3
View File
@@ -0,0 +1,3 @@
# frontend
Vue3 + TS + Element Plus 管理后台,对接 alibb-server 后端(`/api`)。计划见 docs/superpowers/plans/2026-07-14-frontend.md。
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+23
View File
@@ -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'),
)
+13
View File
@@ -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>
+80
View File
@@ -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;
}
}
+7808
View File
File diff suppressed because it is too large Load Diff
+56
View File
@@ -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

+7
View File
@@ -0,0 +1,7 @@
<script setup lang="ts">
import { RouterView } from 'vue-router'
</script>
<template>
<RouterView />
</template>
View File
+204
View File
@@ -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()
})
})
})
+23
View File
@@ -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
}
+61
View File
@@ -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 }
}
+224
View File
@@ -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
}
+128
View File
@@ -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
+69
View File
@@ -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
}
View File
+196
View File
@@ -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>
+508
View File
@@ -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>
View File
+134
View File
@@ -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>
+36
View File
@@ -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')
+75
View File
@@ -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
View File
+104
View File
@@ -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()
})
})
+62
View File
@@ -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()
})
})
})
+120
View File
@@ -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',
})
})
})
})
+69
View File
@@ -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'])
})
})
})
+186
View File
@@ -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 },
])
})
})
+111
View File
@@ -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)
}
}
}
+57
View File
@@ -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
}
+111
View File
@@ -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
}
+54
View File
@@ -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
}
}
+84
View File
@@ -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
}
+55
View File
@@ -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
})
}
View File
+341
View File
@@ -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>
+466
View File
@@ -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>
+137
View File
@@ -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>
+307
View File
@@ -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>
+18
View File
@@ -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