chore: happy 出口通初始提交(前端 React + 后端 Express,采购管理已原生化)
This commit is contained in:
+35
@@ -0,0 +1,35 @@
|
||||
# ---------- 依赖 ----------
|
||||
node_modules/
|
||||
.pnpm-store/
|
||||
|
||||
# ---------- 构建产物 ----------
|
||||
dist/
|
||||
build/
|
||||
|
||||
# ---------- 环境变量(可能含密钥,绝不入库) ----------
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# ---------- 数据库与运行时数据 ----------
|
||||
backend/data/*.db
|
||||
backend/data/*.db-*
|
||||
backend/data/*.sqlite*
|
||||
backend/uploads/
|
||||
|
||||
# ---------- 日志 ----------
|
||||
*.log
|
||||
logs/
|
||||
npm-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# ---------- 编辑器 / 系统 ----------
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
|
||||
# ---------- 临时 ----------
|
||||
tmp/
|
||||
*.tmp
|
||||
@@ -0,0 +1,144 @@
|
||||
# 外贸供应链管理系统
|
||||
|
||||
一个专为外贸企业设计的供应链与客户管理系统。
|
||||
|
||||
## 功能模块
|
||||
|
||||
### 业务管理
|
||||
- **工作台** - 数据汇总、日程提醒、节日显示
|
||||
- **供应商价格管理** - 继承 SKU Manager 完整功能
|
||||
- **产品管理** - SKU/SPU 两层管理、包装参数、阶梯价格
|
||||
- **PI合同管理** - 合同解析、价格历史追踪
|
||||
|
||||
### 客户管理
|
||||
- **客户管理** - 客户信息、联系人、跟进记录
|
||||
- **新客开发** - 自动获客、AI 智能分析
|
||||
- **商机管理** - 销售管道、成交预测
|
||||
- **订单管理** - 订单跟踪、备注跟进
|
||||
|
||||
## 技术栈
|
||||
|
||||
### 后端
|
||||
- Node.js + Express
|
||||
- SQLite + better-sqlite3-multiple-ciphers
|
||||
- 加密存储(SQLCipher)
|
||||
|
||||
### 前端
|
||||
- React 18
|
||||
- React Router
|
||||
- Vite
|
||||
- 原生 CSS
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 安装依赖
|
||||
|
||||
```bash
|
||||
# 后端
|
||||
cd backend
|
||||
npm install
|
||||
|
||||
# 前端
|
||||
cd frontend
|
||||
npm install
|
||||
```
|
||||
|
||||
### 启动开发环境
|
||||
|
||||
```bash
|
||||
# 启动后端服务器(终端1)
|
||||
cd backend
|
||||
npm start
|
||||
|
||||
# 启动前端开发服务器(终端2)
|
||||
cd frontend
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### 访问系统
|
||||
|
||||
- 前端开发地址: http://127.0.0.1:5300
|
||||
- 后端 API 地址: http://127.0.0.1:4300
|
||||
|
||||
### 默认账号
|
||||
|
||||
- 用户名: `admin`
|
||||
- 密码: `admin123`
|
||||
|
||||
**⚠️ 首次登录后请立即修改密码!**
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
trade-management-system/
|
||||
├── backend/ # 后端
|
||||
│ ├── db/ # 数据库
|
||||
│ │ ├── schema.sql # 数据库结构
|
||||
│ │ └── db.js # 数据库连接
|
||||
│ ├── lib/ # 工具库
|
||||
│ ├── routes/ # API 路由
|
||||
│ ├── data/ # 数据存储目录
|
||||
│ ├── uploads/ # 上传文件
|
||||
│ ├── server.js # 服务器入口
|
||||
│ └── package.json
|
||||
├── frontend/ # 前端
|
||||
│ ├── src/
|
||||
│ │ ├── components/ # 组件
|
||||
│ │ ├── pages/ # 页面
|
||||
│ │ ├── services/ # API 服务
|
||||
│ │ ├── styles/ # 样式
|
||||
│ │ ├── App.jsx # 主应用
|
||||
│ │ └── main.jsx # 入口文件
|
||||
│ ├── index.html
|
||||
│ ├── vite.config.js
|
||||
│ └── package.json
|
||||
├── docs/ # 文档
|
||||
└── README.md
|
||||
```
|
||||
|
||||
## 数据库
|
||||
|
||||
使用 SQLite + SQLCipher 加密存储:
|
||||
- 数据库文件: `backend/data/trade.db`
|
||||
- 自动初始化表结构
|
||||
- 支持备份和迁移
|
||||
|
||||
## 开发计划
|
||||
|
||||
### Phase 1:基础框架 ✅
|
||||
- [x] 项目结构搭建
|
||||
- [x] 数据库设计
|
||||
- [x] 前后端基础框架
|
||||
- [x] 工作台页面
|
||||
|
||||
### Phase 2:供应商与产品管理
|
||||
- [ ] 迁移 SKU Manager 功能
|
||||
- [ ] 阶梯价格管理
|
||||
- [ ] 合同解析引擎
|
||||
- [ ] 产品图片管理
|
||||
|
||||
### Phase 3:客户 CRM
|
||||
- [ ] 客户详细页面
|
||||
- [ ] 联系人管理
|
||||
- [ ] 商机管道视图
|
||||
- [ ] 跟进活动时间线
|
||||
|
||||
### Phase 4:订单管理
|
||||
- [ ] 订单创建和编辑
|
||||
- [ ] 订单状态流转
|
||||
- [ ] 订单备注系统
|
||||
- [ ] PDF 导出
|
||||
|
||||
### Phase 5:自动获客
|
||||
- [ ] 集成 GoodJob 获客引擎
|
||||
- [ ] 数据源适配
|
||||
- [ ] AI 智能分析
|
||||
- [ ] 线索转化
|
||||
|
||||
## 许可证
|
||||
|
||||
私有项目
|
||||
|
||||
## 作者
|
||||
|
||||
Wayne
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
const Database = require('better-sqlite3-multiple-ciphers');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, '../data');
|
||||
const DB_PATH = path.join(DATA_DIR, 'trade.db');
|
||||
|
||||
// 确保数据目录存在
|
||||
if (!fs.existsSync(DATA_DIR)) {
|
||||
fs.mkdirSync(DATA_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
// 创建数据库连接
|
||||
const db = new Database(DB_PATH);
|
||||
|
||||
// 启用外键约束
|
||||
db.pragma('foreign_keys = ON');
|
||||
|
||||
// 性能优化
|
||||
db.pragma('journal_mode = WAL');
|
||||
db.pragma('synchronous = NORMAL');
|
||||
|
||||
// 初始化数据库结构
|
||||
function initDatabase() {
|
||||
const schema = fs.readFileSync(path.join(__dirname, 'schema.sql'), 'utf-8');
|
||||
db.exec(schema);
|
||||
console.log('✓ 数据库初始化完成');
|
||||
}
|
||||
|
||||
// 检查数据库是否已初始化
|
||||
const tableExists = db.prepare(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name='users'"
|
||||
).get();
|
||||
|
||||
if (!tableExists) {
|
||||
initDatabase();
|
||||
}
|
||||
|
||||
// 创建默认管理员账号(如果不存在)
|
||||
function createDefaultAdmin() {
|
||||
const crypto = require('crypto');
|
||||
const adminExists = db.prepare('SELECT id FROM users WHERE username = ?').get('admin');
|
||||
|
||||
if (!adminExists) {
|
||||
// 生成默认密码哈希:admin123
|
||||
const salt = crypto.randomBytes(16).toString('hex');
|
||||
const hash = crypto.scryptSync('admin123', salt, 64).toString('hex');
|
||||
const passwordHash = `scrypt$${salt}$${hash}`;
|
||||
|
||||
db.prepare(`
|
||||
INSERT INTO users (username, password_hash, display_name, role, created_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`).run('admin', passwordHash, '管理员', 'admin', new Date().toISOString());
|
||||
|
||||
console.log('✓ 默认管理员账号已创建');
|
||||
console.log(' 用户名: admin');
|
||||
console.log(' 密码: admin123');
|
||||
console.log(' 请登录后立即修改密码!');
|
||||
}
|
||||
}
|
||||
|
||||
createDefaultAdmin();
|
||||
|
||||
module.exports = db;
|
||||
@@ -0,0 +1,402 @@
|
||||
-- ========================================
|
||||
-- 外贸供应链管理系统数据库结构
|
||||
-- ========================================
|
||||
|
||||
-- ================== 供应商与SKU管理 ==================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS suppliers (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
short_name TEXT,
|
||||
contact_person TEXT,
|
||||
phone TEXT,
|
||||
email TEXT,
|
||||
address TEXT,
|
||||
website TEXT,
|
||||
payment_terms TEXT,
|
||||
main_categories TEXT,
|
||||
notes TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS product_groups (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
group_key TEXT,
|
||||
brand TEXT,
|
||||
category TEXT,
|
||||
description TEXT,
|
||||
supplier_id INTEGER REFERENCES suppliers(id),
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS products (
|
||||
sku TEXT PRIMARY KEY,
|
||||
display_sku TEXT,
|
||||
name TEXT,
|
||||
name_en TEXT,
|
||||
brand TEXT,
|
||||
category TEXT,
|
||||
spec TEXT,
|
||||
price REAL,
|
||||
currency TEXT DEFAULT 'CNY',
|
||||
moq INTEGER,
|
||||
description TEXT,
|
||||
supplier_id INTEGER REFERENCES suppliers(id),
|
||||
contract_id INTEGER REFERENCES contracts(id),
|
||||
group_id INTEGER REFERENCES product_groups(id),
|
||||
parse_method TEXT DEFAULT 'rules',
|
||||
last_updated TEXT,
|
||||
source_contract TEXT,
|
||||
confidence TEXT DEFAULT 'high',
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT
|
||||
);
|
||||
|
||||
-- 自定义参数(包装参数、产品规格等)
|
||||
CREATE TABLE IF NOT EXISTS item_attributes (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
group_id INTEGER REFERENCES product_groups(id),
|
||||
sku TEXT,
|
||||
name TEXT NOT NULL,
|
||||
value TEXT,
|
||||
sort INTEGER DEFAULT 0
|
||||
);
|
||||
|
||||
-- 阶梯价格表
|
||||
CREATE TABLE IF NOT EXISTS tier_prices (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
sku TEXT NOT NULL,
|
||||
supplier_id INTEGER REFERENCES suppliers(id),
|
||||
moq_from INTEGER NOT NULL,
|
||||
moq_to INTEGER,
|
||||
price REAL NOT NULL,
|
||||
currency TEXT DEFAULT 'CNY',
|
||||
valid_from TEXT,
|
||||
valid_until TEXT,
|
||||
quote_id INTEGER REFERENCES supplier_quotes(id),
|
||||
notes TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT
|
||||
);
|
||||
|
||||
-- 供应商报价记录
|
||||
CREATE TABLE IF NOT EXISTS supplier_quotes (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
supplier_id INTEGER REFERENCES suppliers(id),
|
||||
quote_no TEXT,
|
||||
quote_date TEXT NOT NULL,
|
||||
valid_until TEXT,
|
||||
currency TEXT DEFAULT 'CNY',
|
||||
payment_terms TEXT,
|
||||
notes TEXT,
|
||||
pdf_file TEXT,
|
||||
status TEXT DEFAULT 'active',
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT
|
||||
);
|
||||
|
||||
-- 价格历史
|
||||
CREATE TABLE IF NOT EXISTS price_history (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
sku TEXT NOT NULL,
|
||||
old_price REAL,
|
||||
new_price REAL,
|
||||
old_moq INTEGER,
|
||||
new_moq INTEGER,
|
||||
currency TEXT,
|
||||
supplier_id INTEGER REFERENCES suppliers(id),
|
||||
source_contract TEXT,
|
||||
confidence TEXT,
|
||||
changed_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- 合同
|
||||
CREATE TABLE IF NOT EXISTS contracts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
filename TEXT NOT NULL,
|
||||
supplier_id INTEGER REFERENCES suppliers(id),
|
||||
file_type TEXT,
|
||||
source_path TEXT,
|
||||
column_report TEXT,
|
||||
terms TEXT,
|
||||
parse_method TEXT DEFAULT 'rules',
|
||||
ai_rejected INTEGER DEFAULT 0,
|
||||
processed_at TEXT NOT NULL,
|
||||
rows_matched INTEGER DEFAULT 0,
|
||||
rows_new INTEGER DEFAULT 0,
|
||||
status TEXT,
|
||||
notes TEXT
|
||||
);
|
||||
|
||||
-- 产品图片
|
||||
CREATE TABLE IF NOT EXISTS product_images (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
sku TEXT NOT NULL,
|
||||
filename TEXT NOT NULL,
|
||||
original_name TEXT,
|
||||
is_primary INTEGER DEFAULT 0,
|
||||
group_id INTEGER REFERENCES product_groups(id),
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- ================== 客户管理(CRM) ==================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS customers (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
company TEXT NOT NULL,
|
||||
company_en TEXT,
|
||||
country TEXT,
|
||||
industry TEXT,
|
||||
website TEXT,
|
||||
address TEXT,
|
||||
phone TEXT,
|
||||
email TEXT,
|
||||
rating TEXT,
|
||||
status TEXT DEFAULT 'active',
|
||||
owner_id INTEGER REFERENCES users(id),
|
||||
source TEXT,
|
||||
tags TEXT,
|
||||
notes TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT,
|
||||
last_contact_at TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS contacts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
customer_id INTEGER NOT NULL REFERENCES customers(id),
|
||||
name TEXT NOT NULL,
|
||||
name_en TEXT,
|
||||
position TEXT,
|
||||
department TEXT,
|
||||
email TEXT,
|
||||
phone TEXT,
|
||||
whatsapp TEXT,
|
||||
wechat TEXT,
|
||||
is_primary INTEGER DEFAULT 0,
|
||||
notes TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT
|
||||
);
|
||||
|
||||
-- 商机
|
||||
CREATE TABLE IF NOT EXISTS deals (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
customer_id INTEGER NOT NULL REFERENCES customers(id),
|
||||
title TEXT NOT NULL,
|
||||
amount REAL,
|
||||
currency TEXT DEFAULT 'USD',
|
||||
stage TEXT NOT NULL,
|
||||
probability INTEGER,
|
||||
expected_close_date TEXT,
|
||||
actual_close_date TEXT,
|
||||
products TEXT,
|
||||
owner_id INTEGER REFERENCES users(id),
|
||||
status TEXT DEFAULT 'open',
|
||||
notes TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT
|
||||
);
|
||||
|
||||
-- 跟进活动
|
||||
CREATE TABLE IF NOT EXISTS activities (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
customer_id INTEGER REFERENCES customers(id),
|
||||
deal_id INTEGER REFERENCES deals(id),
|
||||
order_id INTEGER REFERENCES orders(id),
|
||||
activity_type TEXT NOT NULL,
|
||||
subject TEXT,
|
||||
content TEXT,
|
||||
files TEXT,
|
||||
next_action TEXT,
|
||||
next_action_date TEXT,
|
||||
created_by INTEGER REFERENCES users(id),
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- 待办事项
|
||||
CREATE TABLE IF NOT EXISTS todos (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT,
|
||||
due_date TEXT,
|
||||
priority TEXT DEFAULT 'medium',
|
||||
status TEXT DEFAULT 'pending',
|
||||
customer_id INTEGER REFERENCES customers(id),
|
||||
deal_id INTEGER REFERENCES deals(id),
|
||||
order_id INTEGER REFERENCES orders(id),
|
||||
assigned_to INTEGER REFERENCES users(id),
|
||||
created_by INTEGER REFERENCES users(id),
|
||||
created_at TEXT NOT NULL,
|
||||
completed_at TEXT
|
||||
);
|
||||
|
||||
-- ================== 订单管理 ==================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS orders (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
order_no TEXT NOT NULL UNIQUE,
|
||||
customer_id INTEGER NOT NULL REFERENCES customers(id),
|
||||
order_date TEXT NOT NULL,
|
||||
delivery_date TEXT,
|
||||
payment_terms TEXT,
|
||||
total_amount REAL,
|
||||
currency TEXT DEFAULT 'USD',
|
||||
status TEXT DEFAULT 'draft',
|
||||
owner_id INTEGER REFERENCES users(id),
|
||||
notes TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS order_items (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
order_id INTEGER NOT NULL REFERENCES orders(id),
|
||||
sku TEXT NOT NULL,
|
||||
product_name TEXT,
|
||||
qty INTEGER NOT NULL,
|
||||
unit_price REAL NOT NULL,
|
||||
total_price REAL NOT NULL,
|
||||
currency TEXT DEFAULT 'USD',
|
||||
notes TEXT,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- ================== 客户报价单 ==================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS customer_quotations (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
quote_no TEXT NOT NULL UNIQUE,
|
||||
customer_id INTEGER NOT NULL REFERENCES customers(id),
|
||||
quote_date TEXT NOT NULL,
|
||||
valid_until TEXT,
|
||||
currency TEXT DEFAULT 'USD',
|
||||
payment_terms TEXT,
|
||||
delivery_terms TEXT,
|
||||
status TEXT DEFAULT 'draft',
|
||||
notes TEXT,
|
||||
created_by INTEGER REFERENCES users(id),
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS quotation_items (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
quotation_id INTEGER NOT NULL REFERENCES customer_quotations(id),
|
||||
sku TEXT NOT NULL,
|
||||
product_name TEXT,
|
||||
product_name_en TEXT,
|
||||
spec TEXT,
|
||||
qty INTEGER NOT NULL,
|
||||
unit_price REAL NOT NULL,
|
||||
total_price REAL NOT NULL,
|
||||
currency TEXT DEFAULT 'USD',
|
||||
notes TEXT,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- ================== 自动获客(新客开发) ==================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS prospect_runs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
product_keywords TEXT,
|
||||
countries TEXT,
|
||||
customer_types TEXT,
|
||||
exclude_keywords TEXT,
|
||||
sources TEXT,
|
||||
result_limit INTEGER DEFAULT 100,
|
||||
status TEXT DEFAULT 'draft',
|
||||
owner_id INTEGER REFERENCES users(id),
|
||||
created_at TEXT NOT NULL,
|
||||
completed_at TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS prospect_leads (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
run_id INTEGER REFERENCES prospect_runs(id),
|
||||
company TEXT NOT NULL,
|
||||
website TEXT,
|
||||
country TEXT,
|
||||
business TEXT,
|
||||
contact TEXT,
|
||||
email TEXT,
|
||||
phone TEXT,
|
||||
description TEXT,
|
||||
icp_score INTEGER,
|
||||
confidence INTEGER,
|
||||
source TEXT,
|
||||
source_url TEXT,
|
||||
status TEXT DEFAULT 'new',
|
||||
owner_id INTEGER REFERENCES users(id),
|
||||
customer_id INTEGER REFERENCES customers(id),
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- ================== 系统管理 ==================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
password_hash TEXT NOT NULL,
|
||||
display_name TEXT,
|
||||
email TEXT,
|
||||
role TEXT DEFAULT 'user',
|
||||
is_active INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TEXT NOT NULL,
|
||||
last_login_at TEXT,
|
||||
failed_count INTEGER NOT NULL DEFAULT 0,
|
||||
locked_until TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||
created_at TEXT NOT NULL,
|
||||
last_seen_at TEXT NOT NULL,
|
||||
expires_at TEXT NOT NULL,
|
||||
user_agent TEXT,
|
||||
ip TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS audit_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
at TEXT NOT NULL,
|
||||
username TEXT,
|
||||
action TEXT NOT NULL,
|
||||
detail TEXT,
|
||||
ip TEXT,
|
||||
user_agent TEXT,
|
||||
ok INTEGER NOT NULL DEFAULT 1
|
||||
);
|
||||
|
||||
-- ================== 索引 ==================
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_products_supplier ON products(supplier_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_products_group ON products(group_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_products_name ON products(name);
|
||||
CREATE INDEX IF NOT EXISTS idx_tier_prices_sku ON tier_prices(sku);
|
||||
CREATE INDEX IF NOT EXISTS idx_price_history_sku ON price_history(sku);
|
||||
CREATE INDEX IF NOT EXISTS idx_customers_owner ON customers(owner_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_customers_country ON customers(country);
|
||||
CREATE INDEX IF NOT EXISTS idx_contacts_customer ON contacts(customer_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_deals_customer ON deals(customer_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_deals_owner ON deals(owner_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_activities_customer ON activities(customer_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_activities_deal ON activities(deal_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_orders_customer ON orders(customer_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_order_items_order ON order_items(order_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_quotations_customer ON customer_quotations(customer_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_quotation_items_quotation ON quotation_items(quotation_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_prospect_leads_run ON prospect_leads(run_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_exp ON sessions(expires_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_at ON audit_log(at);
|
||||
Generated
+1433
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "trade-management-backend",
|
||||
"version": "1.0.0",
|
||||
"description": "外贸供应链管理系统 - 后端",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
"start": "node server.js",
|
||||
"dev": "nodemon server.js"
|
||||
},
|
||||
"keywords": ["trade", "crm", "supply-chain"],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"better-sqlite3-multiple-ciphers": "^13.0.3",
|
||||
"cors": "^2.8.5",
|
||||
"express": "^4.18.2",
|
||||
"multer": "^1.4.5-lts.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"nodemon": "^3.0.1"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,360 @@
|
||||
const express = require('express');
|
||||
const path = require('path');
|
||||
const cors = require('cors');
|
||||
const http = require('http');
|
||||
const db = require('./db/db');
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 4300;
|
||||
const HOST = '127.0.0.1';
|
||||
|
||||
// 中间件
|
||||
app.use(cors());
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SKU 服务代理(过渡期)
|
||||
// 把 /api/sku/* 原样转发给独立的 sku-manager 服务,让前端在正式合并(P1)完成前
|
||||
// 也能以同源路径访问 SKU 数据。必须放在 express.json() 之前,保持请求体的流式转发。
|
||||
// 合并完成后可整体移除,并改用本地路由。
|
||||
// ---------------------------------------------------------------------------
|
||||
const SKU_ORIGIN = process.env.SKU_ORIGIN || 'http://127.0.0.1:3300';
|
||||
|
||||
/**
|
||||
* 把请求原样转发到 sku-manager 服务。
|
||||
* @param {string} targetPath 转发到 SKU 服务的目标路径
|
||||
*/
|
||||
function proxyToSku(targetPath) {
|
||||
return (req, res) => {
|
||||
let target;
|
||||
try {
|
||||
target = new URL(SKU_ORIGIN);
|
||||
} catch {
|
||||
return res.status(500).json({ error: `SKU_ORIGIN 配置无效:${SKU_ORIGIN}` });
|
||||
}
|
||||
|
||||
const proxyReq = http.request(
|
||||
{
|
||||
hostname: target.hostname,
|
||||
port: target.port || 80,
|
||||
path: targetPath,
|
||||
method: req.method,
|
||||
headers: { ...req.headers, host: target.host }
|
||||
},
|
||||
(proxyRes) => {
|
||||
res.writeHead(proxyRes.statusCode, proxyRes.headers);
|
||||
proxyRes.pipe(res);
|
||||
}
|
||||
);
|
||||
|
||||
proxyReq.on('error', (err) => {
|
||||
if (!res.headersSent) {
|
||||
res.status(502).json({ error: `SKU 服务不可用(${SKU_ORIGIN}):${err.message}` });
|
||||
}
|
||||
});
|
||||
|
||||
req.pipe(proxyReq);
|
||||
};
|
||||
}
|
||||
|
||||
// /api/sku/suppliers -> http://127.0.0.1:3300/api/suppliers
|
||||
app.use('/api/sku', (req, res) => proxyToSku('/api' + req.url)(req, res));
|
||||
// /product-images/xxx.jpg -> http://127.0.0.1:3300/product-images/xxx.jpg
|
||||
app.use('/product-images', (req, res) => proxyToSku('/product-images' + req.url)(req, res));
|
||||
|
||||
app.use(express.json());
|
||||
app.use(express.urlencoded({ extended: true }));
|
||||
|
||||
// 静态文件服务
|
||||
app.use(express.static(path.join(__dirname, '../frontend/dist')));
|
||||
|
||||
// API 路由
|
||||
app.get('/api/health', (req, res) => {
|
||||
res.json({
|
||||
status: 'ok',
|
||||
version: '1.0.0',
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
});
|
||||
|
||||
// 获取工作台数据
|
||||
app.get('/api/dashboard', (req, res) => {
|
||||
try {
|
||||
// 统计数据
|
||||
const stats = {
|
||||
suppliers: db.prepare('SELECT COUNT(*) as count FROM suppliers').get().count,
|
||||
products: db.prepare('SELECT COUNT(*) as count FROM products').get().count,
|
||||
customers: db.prepare('SELECT COUNT(*) as count FROM customers').get().count,
|
||||
orders: db.prepare(`SELECT COUNT(*) as count FROM orders WHERE status != 'cancelled'`).get().count,
|
||||
activeDeals: db.prepare(`SELECT COUNT(*) as count FROM deals WHERE status = 'open'`).get().count,
|
||||
pendingTodos: db.prepare(`SELECT COUNT(*) as count FROM todos WHERE status = 'pending'`).get().count
|
||||
};
|
||||
|
||||
// 最近活动
|
||||
const recentActivities = db.prepare(`
|
||||
SELECT id, activity_type, subject, content, created_at
|
||||
FROM activities
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 10
|
||||
`).all();
|
||||
|
||||
// 待办事项
|
||||
const todos = db.prepare(`
|
||||
SELECT t.*, c.company as customer_name
|
||||
FROM todos t
|
||||
LEFT JOIN customers c ON t.customer_id = c.id
|
||||
WHERE t.status = 'pending'
|
||||
ORDER BY t.due_date ASC, t.priority DESC
|
||||
LIMIT 10
|
||||
`).all();
|
||||
|
||||
res.json({
|
||||
stats,
|
||||
recentActivities,
|
||||
todos
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('获取工作台数据失败:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// 供应商路由
|
||||
app.get('/api/suppliers', (req, res) => {
|
||||
try {
|
||||
const suppliers = db.prepare('SELECT * FROM suppliers ORDER BY name').all();
|
||||
res.json(suppliers);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/suppliers', (req, res) => {
|
||||
try {
|
||||
const { name, contact_person, phone, email, address, payment_terms, notes } = req.body;
|
||||
const result = db.prepare(`
|
||||
INSERT INTO suppliers (name, contact_person, phone, email, address, payment_terms, notes, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(name, contact_person, phone, email, address, payment_terms, notes, new Date().toISOString());
|
||||
|
||||
res.json({ id: result.lastInsertRowid, message: '供应商创建成功' });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// 产品路由
|
||||
app.get('/api/products', (req, res) => {
|
||||
try {
|
||||
const products = db.prepare(`
|
||||
SELECT p.*, s.name as supplier_name, pg.name as group_name
|
||||
FROM products p
|
||||
LEFT JOIN suppliers s ON p.supplier_id = s.id
|
||||
LEFT JOIN product_groups pg ON p.group_id = pg.id
|
||||
ORDER BY p.name
|
||||
`).all();
|
||||
res.json(products);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// 客户路由
|
||||
app.get('/api/customers', (req, res) => {
|
||||
try {
|
||||
const customers = db.prepare(`
|
||||
SELECT c.*, u.display_name as owner_name
|
||||
FROM customers c
|
||||
LEFT JOIN users u ON c.owner_id = u.id
|
||||
ORDER BY c.created_at DESC
|
||||
`).all();
|
||||
res.json(customers);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/customers', (req, res) => {
|
||||
try {
|
||||
const { company, country, industry, website, email, phone, notes, owner_id } = req.body;
|
||||
const result = db.prepare(`
|
||||
INSERT INTO customers (company, country, industry, website, email, phone, notes, owner_id, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(company, country, industry, website, email, phone, notes, owner_id, new Date().toISOString());
|
||||
|
||||
res.json({ id: result.lastInsertRowid, message: '客户创建成功' });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// 订单路由
|
||||
app.get('/api/orders', (req, res) => {
|
||||
try {
|
||||
const orders = db.prepare(`
|
||||
SELECT o.*, c.company as customer_name
|
||||
FROM orders o
|
||||
LEFT JOIN customers c ON o.customer_id = c.id
|
||||
ORDER BY o.order_date DESC
|
||||
`).all();
|
||||
res.json(orders);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// 商机路由
|
||||
app.get('/api/deals', (req, res) => {
|
||||
try {
|
||||
const deals = db.prepare(`
|
||||
SELECT d.*, c.company as customer_name
|
||||
FROM deals d
|
||||
LEFT JOIN customers c ON d.customer_id = c.id
|
||||
WHERE d.status = 'open'
|
||||
ORDER BY d.expected_close_date
|
||||
`).all();
|
||||
res.json(deals);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Assistant read layer: every query is explicit and read-only unless a future
|
||||
// command is added to the allowlist below.
|
||||
function getAssistantContext() {
|
||||
const stats = {
|
||||
suppliers: db.prepare('SELECT COUNT(*) as count FROM suppliers').get().count,
|
||||
products: db.prepare('SELECT COUNT(*) as count FROM products').get().count,
|
||||
customers: db.prepare('SELECT COUNT(*) as count FROM customers').get().count,
|
||||
orders: db.prepare(`SELECT COUNT(*) as count FROM orders WHERE status != 'cancelled'`).get().count,
|
||||
deals: db.prepare(`SELECT COUNT(*) as count FROM deals WHERE status = 'open'`).get().count,
|
||||
todos: db.prepare(`SELECT COUNT(*) as count FROM todos WHERE status = 'pending'`).get().count
|
||||
};
|
||||
return {
|
||||
generatedAt: new Date().toISOString(),
|
||||
stats,
|
||||
recent: {
|
||||
suppliers: db.prepare('SELECT id, name, contact_person, phone FROM suppliers ORDER BY created_at DESC LIMIT 8').all(),
|
||||
products: db.prepare('SELECT sku, sku as id, name, brand, supplier_id, confidence as status FROM products ORDER BY created_at DESC LIMIT 8').all(),
|
||||
customers: db.prepare('SELECT id, company, country, industry, status, last_contact_at FROM customers ORDER BY created_at DESC LIMIT 8').all(),
|
||||
orders: db.prepare('SELECT id, order_no, customer_id, order_date, total_amount, currency, status FROM orders ORDER BY order_date DESC LIMIT 8').all(),
|
||||
deals: db.prepare(`SELECT id, title, customer_id, amount, currency, stage, expected_close_date FROM deals WHERE status = 'open' ORDER BY expected_close_date LIMIT 8`).all(),
|
||||
todos: db.prepare(`SELECT id, title, due_date, priority, status, customer_id FROM todos WHERE status = 'pending' ORDER BY due_date ASC LIMIT 8`).all(),
|
||||
activities: db.prepare('SELECT id, activity_type, subject, content, created_at FROM activities ORDER BY created_at DESC LIMIT 8').all()
|
||||
},
|
||||
capabilities: [
|
||||
{ id: 'read.dashboard', label: '读取工作台概览' },
|
||||
{ id: 'read.suppliers', label: '检索供应商与联系人' },
|
||||
{ id: 'read.products', label: '检索产品与 SKU' },
|
||||
{ id: 'read.customers', label: '检索客户与跟进状态' },
|
||||
{ id: 'read.orders', label: '查询订单与状态' },
|
||||
{ id: 'read.deals', label: '查询商机与预计成交' },
|
||||
{ id: 'read.todos', label: '查询待办与截止日期' },
|
||||
{ id: 'write.confirmed', label: '执行已确认的业务写操作' }
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
app.get('/api/assistant/context', (req, res) => {
|
||||
try {
|
||||
res.json(getAssistantContext());
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/assistant/command', (req, res) => {
|
||||
try {
|
||||
const prompt = String(req.body?.prompt || '').trim();
|
||||
if (!prompt) return res.status(400).json({ error: '请输入指令' });
|
||||
const context = getAssistantContext();
|
||||
const { stats } = context;
|
||||
let reply = `已读取系统数据:${stats.customers} 个客户、${stats.products} 个产品、${stats.orders} 笔进行中订单、${stats.deals} 个开放商机、${stats.todos} 项待办。`;
|
||||
let meta = 'READ_ONLY_CONTEXT';
|
||||
if (/待办|任务|日程/.test(prompt)) {
|
||||
const items = context.recent.todos.slice(0, 5).map(item => item.title).join('、');
|
||||
reply = items ? `当前有 ${stats.todos} 项待办:${items}。` : '当前没有待处理的待办事项。';
|
||||
} else if (/客户/.test(prompt)) {
|
||||
const items = context.recent.customers.slice(0, 5).map(item => item.company).join('、');
|
||||
reply = items ? `我找到 ${stats.customers} 个客户,最近记录包括:${items}。` : '当前还没有客户数据。';
|
||||
} else if (/产品|SKU/.test(prompt)) {
|
||||
const items = context.recent.products.slice(0, 5).map(item => item.name || item.sku).join('、');
|
||||
reply = items ? `当前有 ${stats.products} 个产品,最近记录包括:${items}。` : '当前还没有产品数据。';
|
||||
} else if (/供应商/.test(prompt)) {
|
||||
const items = context.recent.suppliers.slice(0, 5).map(item => item.name).join('、');
|
||||
reply = items ? `当前有 ${stats.suppliers} 个供应商,最近记录包括:${items}。` : '当前还没有供应商数据。';
|
||||
} else if (/订单/.test(prompt)) {
|
||||
reply = stats.orders ? `当前有 ${stats.orders} 笔进行中订单,最近订单状态已同步到系统上下文。` : '当前没有进行中的订单。';
|
||||
} else if (/新建|新增|创建|修改|更新|删除|完成|标记/.test(prompt)) {
|
||||
meta = 'CONFIRMATION_REQUIRED';
|
||||
reply = '我已经识别到这是一个可能改变系统数据的操作。当前入口已连接系统能力层,但在真正写入前需要确认具体对象、字段和目标;请先说明要操作的业务对象。';
|
||||
}
|
||||
res.json({ reply, meta, context });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
|
||||
// 全局搜索:仅索引业务表的非敏感字段,查询参数使用绑定变量。
|
||||
const SEARCH_SOURCES = [
|
||||
{ table: 'suppliers', type: 'supplier', typeLabel: '供应商', title: ['name', 'short_name'], detail: ['contact_person', 'phone', 'email', 'address', 'website', 'payment_terms', 'main_categories', 'notes'], path: '/suppliers' },
|
||||
{ table: 'product_groups', type: 'product', typeLabel: '产品分组', title: ['name', 'group_key'], detail: ['brand', 'category', 'notes'], path: '/products' },
|
||||
{ table: 'products', type: 'product', typeLabel: '产品', title: ['name', 'sku'], detail: ['display_sku', 'name_en', 'brand', 'category', 'spec', 'price', 'currency', 'moq', 'description', 'parse_method', 'source_contract', 'confidence'], path: '/products' },
|
||||
{ table: 'item_attributes', type: 'product', typeLabel: '产品参数', title: ['item_sku', 'key'], detail: ['value'], path: '/products' },
|
||||
{ table: 'tier_prices', type: 'product', typeLabel: '阶梯价格', title: ['sku'], detail: ['moq_from', 'moq_to', 'price', 'currency', 'valid_from', 'valid_until', 'notes'], path: '/products' },
|
||||
{ table: 'supplier_quotes', type: 'supplier', typeLabel: '供应商报价', title: ['quote_no'], detail: ['quote_date', 'valid_until', 'currency', 'payment_terms', 'notes', 'status'], path: '/suppliers' },
|
||||
{ table: 'price_history', type: 'product', typeLabel: '价格历史', title: ['sku'], detail: ['old_price', 'new_price', 'old_moq', 'new_moq', 'currency', 'source_contract', 'confidence', 'changed_at'], path: '/products' },
|
||||
{ table: 'customers', type: 'customer', typeLabel: '客户', title: ['company', 'company_en'], detail: ['country', 'industry', 'website', 'address', 'phone', 'email', 'rating', 'status', 'source', 'tags', 'notes', 'last_contact_at'], path: '/customers' },
|
||||
{ table: 'contacts', type: 'customer', typeLabel: '客户联系人', title: ['name', 'name_en'], detail: ['position', 'department', 'email', 'phone', 'whatsapp', 'wechat', 'notes'], path: '/customers' },
|
||||
{ table: 'orders', type: 'order', typeLabel: '订单', title: ['order_no'], detail: ['order_date', 'delivery_date', 'payment_terms', 'total_amount', 'currency', 'status', 'notes'], path: '/orders' },
|
||||
{ table: 'order_items', type: 'order', typeLabel: '订单明细', title: ['product_name', 'sku'], detail: ['qty', 'unit_price', 'amount', 'notes'], path: '/orders' },
|
||||
{ table: 'deals', type: 'deal', typeLabel: '商机', title: ['title'], detail: ['amount', 'currency', 'stage', 'probability', 'expected_close_date', 'actual_close_date', 'products', 'status', 'notes'], path: '/prospects' },
|
||||
{ table: 'todos', type: 'todo', typeLabel: '待办', title: ['title'], detail: ['description', 'due_date', 'priority', 'status'], path: '/' },
|
||||
{ table: 'activities', type: 'activity', typeLabel: '活动', title: ['subject'], detail: ['activity_type', 'content', 'next_action', 'next_action_date'], path: '/' }
|
||||
];
|
||||
|
||||
function existingColumns(table) {
|
||||
return new Set(db.prepare(`PRAGMA table_info(${table})`).all().map(column => column.name));
|
||||
}
|
||||
|
||||
app.get('/api/search', (req, res) => {
|
||||
try {
|
||||
const term = String(req.query.q || '').trim().slice(0, 100);
|
||||
if (!term) return res.json({ results: [] });
|
||||
const pattern = `%${term}%`;
|
||||
const results = [];
|
||||
for (const source of SEARCH_SOURCES) {
|
||||
const columns = existingColumns(source.table);
|
||||
const searchable = [...source.title, ...source.detail].filter(column => columns.has(column));
|
||||
if (!searchable.length) continue;
|
||||
const where = searchable.map(column => `CAST(${column} AS TEXT) LIKE ?`).join(' OR ');
|
||||
const selected = ['rowid AS _search_id', ...searchable].join(', ');
|
||||
const rows = db.prepare(`SELECT ${selected} FROM ${source.table} WHERE ${where} LIMIT 8`).all(...searchable.map(() => pattern));
|
||||
rows.forEach(row => {
|
||||
const title = source.title.map(column => row[column]).filter(Boolean).join(' · ') || source.typeLabel;
|
||||
const detail = source.detail.map(column => row[column]).filter(value => value !== undefined && value !== null && value !== '').join(' · ');
|
||||
results.push({ id: row._search_id, type: source.type, typeLabel: source.typeLabel, title, detail: detail || '匹配到业务记录', path: source.path });
|
||||
});
|
||||
}
|
||||
res.json({ results: results.slice(0, 30) });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// SPA fallback - 所有未匹配的路由返回 index.html
|
||||
app.get('*', (req, res) => {
|
||||
res.sendFile(path.join(__dirname, '../frontend/dist/index.html'));
|
||||
});
|
||||
|
||||
// 启动服务器
|
||||
app.listen(PORT, HOST, () => {
|
||||
console.log('========================================');
|
||||
console.log(' 外贸供应链管理系统');
|
||||
console.log('========================================');
|
||||
console.log(` 服务器地址: http://${HOST}:${PORT}`);
|
||||
console.log(` 启动时间: ${new Date().toLocaleString('zh-CN')}`);
|
||||
console.log('========================================');
|
||||
});
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
# 项目开发指南
|
||||
|
||||
## 当前进度
|
||||
|
||||
✅ **Phase 1 已完成:基础框架搭建**
|
||||
|
||||
### 已完成的工作
|
||||
|
||||
1. **项目结构**
|
||||
- 前后端分离架构
|
||||
- 标准化目录结构
|
||||
- 开发环境配置
|
||||
|
||||
2. **数据库设计**
|
||||
- 完整的表结构设计
|
||||
- 索引优化
|
||||
- SQLite + SQLCipher 加密
|
||||
|
||||
3. **后端 API**
|
||||
- Express 服务器
|
||||
- 数据库连接层
|
||||
- 基础 CRUD 接口
|
||||
- 健康检查接口
|
||||
|
||||
4. **前端页面**
|
||||
- React + React Router
|
||||
- 6个核心页面:
|
||||
- 工作台(Dashboard)
|
||||
- 供应商管理
|
||||
- 产品管理
|
||||
- 客户管理
|
||||
- 订单管理
|
||||
- 新客开发
|
||||
- 响应式布局
|
||||
- 完整的样式系统
|
||||
|
||||
## 下一步工作
|
||||
|
||||
### 立即可做
|
||||
|
||||
1. **安装依赖并启动**
|
||||
```bash
|
||||
cd D:\wayne\Personal\trade-management-system
|
||||
|
||||
# 安装后端依赖
|
||||
cd backend
|
||||
npm install
|
||||
|
||||
# 安装前端依赖
|
||||
cd ..\frontend
|
||||
npm install
|
||||
|
||||
# 或者直接双击 start.bat
|
||||
```
|
||||
|
||||
2. **测试基础功能**
|
||||
- 访问 http://127.0.0.1:5300
|
||||
- 测试供应商和客户的增删改查
|
||||
- 检查工作台数据展示
|
||||
|
||||
### 短期规划(1-2周)
|
||||
|
||||
1. **迁移 SKU Manager 核心功能**
|
||||
- 复制合同解析引擎
|
||||
- 复制产品管理逻辑
|
||||
- 复制图片处理功能
|
||||
- 复制 AI 配置模块
|
||||
|
||||
2. **完善供应商管理**
|
||||
- 供应商详情页
|
||||
- 阶梯价格表录入
|
||||
- 报价记录管理
|
||||
- 导入导出功能
|
||||
|
||||
3. **完善产品管理**
|
||||
- 产品详情页
|
||||
- SKU/SPU 两层管理
|
||||
- 包装参数编辑
|
||||
- 产品图片上传
|
||||
|
||||
### 中期规划(3-4周)
|
||||
|
||||
1. **客户 CRM**
|
||||
- 客户详情页
|
||||
- 联系人管理
|
||||
- 跟进记录时间线
|
||||
- 商机管道视图
|
||||
|
||||
2. **订单管理**
|
||||
- 订单创建向导
|
||||
- 订单详情页
|
||||
- 订单状态流转
|
||||
- 订单备注系统
|
||||
|
||||
3. **报价单生成**
|
||||
- 客户报价单
|
||||
- PDF 导出
|
||||
- 模板定制
|
||||
|
||||
### 长期规划(1-2个月)
|
||||
|
||||
1. **自动获客**
|
||||
- 集成 GoodJob 获客引擎
|
||||
- 数据源适配器
|
||||
- AI 智能分析
|
||||
- 线索转化流程
|
||||
|
||||
2. **高级功能**
|
||||
- 权限管理系统
|
||||
- 数据分析报表
|
||||
- 导入导出优化
|
||||
- 移动端适配
|
||||
|
||||
## 技术决策记录
|
||||
|
||||
### 为什么选择 SQLite?
|
||||
- 单文件数据库,备份简单
|
||||
- 支持加密(SQLCipher)
|
||||
- 适合中小规模(10万级数据)
|
||||
- 零配置,易于部署
|
||||
|
||||
### 为什么选择 React?
|
||||
- 组件化开发效率高
|
||||
- 生态系统成熟
|
||||
- 与 GoodJob 技术栈一致
|
||||
|
||||
### 为什么不用现成的 ERP?
|
||||
- ERPNext 过于复杂,定制成本高
|
||||
- 需要高度定制化的业务流程
|
||||
- 完全掌控代码和数据
|
||||
|
||||
## 常见问题
|
||||
|
||||
### Q: 如何备份数据?
|
||||
A: 直接复制 `backend/data/trade.db` 文件
|
||||
|
||||
### Q: 如何迁移到 MySQL?
|
||||
A: 修改 `backend/db/db.js` 使用 mysql2 驱动
|
||||
|
||||
### Q: 如何部署到服务器?
|
||||
A:
|
||||
1. 后端:`npm start` 或 PM2
|
||||
2. 前端:`npm run build` 后用 Nginx 托管
|
||||
3. 配置反向代理
|
||||
|
||||
### Q: 如何集成 SKU Manager?
|
||||
A:
|
||||
1. 复制 `lib/` 目录下的解析器
|
||||
2. 复制 `parsers/` 目录
|
||||
3. 调整数据库表结构对应关系
|
||||
|
||||
## 联系方式
|
||||
|
||||
项目负责人:Wayne
|
||||
@@ -0,0 +1,21 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>happy出口通</title>
|
||||
<script>
|
||||
try {
|
||||
const theme = localStorage.getItem('tms-theme') === 'light' ? 'light' : 'dark';
|
||||
document.documentElement.dataset.theme = theme;
|
||||
document.documentElement.style.colorScheme = theme;
|
||||
document.documentElement.dataset.motion = localStorage.getItem('tms-motion') === 'reduced' ? 'reduced' : 'full';
|
||||
} catch (_) { document.documentElement.dataset.theme = 'dark'; }
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Generated
+2126
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "trade-management-frontend",
|
||||
"version": "1.0.0",
|
||||
"description": "外贸供应链管理系统 - 前端",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host 127.0.0.1 --port 5300",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-router-dom": "^6.20.0",
|
||||
"axios": "^1.6.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.2.43",
|
||||
"@types/react-dom": "^18.2.17",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"vite": "^5.0.8"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
import React from 'react'
|
||||
import { BrowserRouter as Router, Routes, Route, NavLink, Navigate } from 'react-router-dom'
|
||||
import Dashboard from './pages/Dashboard'
|
||||
import EmbeddedSku from './pages/EmbeddedSku'
|
||||
import ProductManagement from './pages/ProductManagement'
|
||||
import SupplierManagement from './pages/SupplierManagement'
|
||||
import CustomerManagement from './pages/CustomerManagement'
|
||||
import OrderManagement from './pages/OrderManagement'
|
||||
import ProspectManagement from './pages/ProspectManagement'
|
||||
import QuoteManagement from './pages/QuoteManagement'
|
||||
import PiContractManagement from './pages/PiContractManagement'
|
||||
import PurchaseOrderManagement from './pages/PurchaseOrderManagement'
|
||||
import Settings from './components/Settings'
|
||||
import GlobalHeader from './components/GlobalHeader'
|
||||
import AIAssistant from './components/AIAssistant'
|
||||
import { readPreference } from './preferences'
|
||||
import {
|
||||
DashboardIcon,
|
||||
FactoryIcon,
|
||||
PackageIcon,
|
||||
UsersIcon,
|
||||
TargetIcon,
|
||||
ClipboardIcon,
|
||||
DownloadIcon,
|
||||
QuoteIcon,
|
||||
ContractIcon,
|
||||
PurchaseIcon,
|
||||
ChevronDownIcon,
|
||||
TuneIcon,
|
||||
SunIcon,
|
||||
MoonIcon,
|
||||
SidebarLayoutIcon
|
||||
} from './components/Icons'
|
||||
|
||||
/* 侧边栏分组配置:工作台独立置顶,不归属任何分组 */
|
||||
const NAV_GROUPS = [
|
||||
{
|
||||
id: 'business',
|
||||
title: '业务管理',
|
||||
items: [
|
||||
{ to: '/quotes', label: '客户报价单管理', icon: QuoteIcon },
|
||||
{ to: '/pi-contracts', label: 'PI合同管理', icon: ContractIcon },
|
||||
{ to: '/purchase-orders', label: '采购单管理', icon: PurchaseIcon }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'customer',
|
||||
title: '客户管理',
|
||||
items: [
|
||||
{ to: '/customers', label: '客户管理', icon: UsersIcon },
|
||||
{ to: '/prospects', label: '新客开发', icon: TargetIcon },
|
||||
{ to: '/orders', label: '订单管理', icon: ClipboardIcon }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'procurement',
|
||||
title: '采购管理',
|
||||
items: [
|
||||
{ to: '/products', label: '产品管理', icon: PackageIcon },
|
||||
{ to: '/suppliers', label: '供应商管理', icon: FactoryIcon },
|
||||
{ to: '/procurement/import', label: '合同导入', icon: DownloadIcon }
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
function App() {
|
||||
const [collapsed, setCollapsed] = React.useState(false)
|
||||
const [collapsedGroups, setCollapsedGroups] = React.useState([])
|
||||
const [settingsOpen, setSettingsOpen] = React.useState(false)
|
||||
const [assistantOpen, setAssistantOpen] = React.useState(false)
|
||||
const [theme, setTheme] = React.useState(() => readPreference('tms-theme', ['dark', 'light'], 'dark'))
|
||||
|
||||
const [reducedMotion, setReducedMotion] = React.useState(() => readPreference('tms-motion', ['full', 'reduced'], 'full') === 'reduced')
|
||||
const closeSettings = React.useCallback(() => setSettingsOpen(false), [])
|
||||
const closeAssistant = React.useCallback(() => setAssistantOpen(false), [])
|
||||
|
||||
React.useEffect(() => {
|
||||
document.documentElement.dataset.theme = theme
|
||||
document.documentElement.style.colorScheme = theme
|
||||
try { localStorage.setItem('tms-theme', theme) } catch { /* Session-only when storage is unavailable. */ }
|
||||
}, [theme])
|
||||
|
||||
React.useEffect(() => {
|
||||
document.documentElement.dataset.motion = reducedMotion ? 'reduced' : 'full'
|
||||
try { localStorage.setItem('tms-motion', reducedMotion ? 'reduced' : 'full') } catch { /* Session-only. */ }
|
||||
}, [reducedMotion])
|
||||
|
||||
const navClass = ({ isActive }) => `nav-item${isActive ? ' active' : ''}`
|
||||
|
||||
const toggleGroup = React.useCallback((id) => {
|
||||
setCollapsedGroups(current => current.includes(id) ? current.filter(x => x !== id) : [...current, id])
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<Router>
|
||||
<div className="app-container">
|
||||
<aside inert={settingsOpen || assistantOpen ? "" : undefined} className={`sidebar ${collapsed ? 'collapsed' : ''}`}>
|
||||
<div className="sidebar-header">
|
||||
<h1>{collapsed ? 'HET' : 'happy出口通'}</h1>
|
||||
<button
|
||||
onClick={() => setCollapsed(!collapsed)}
|
||||
className="collapse-btn"
|
||||
aria-label={collapsed ? '展开导航' : '收起导航'}
|
||||
title={collapsed ? '展开导航' : '收起导航'}
|
||||
>
|
||||
<SidebarLayoutIcon size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<nav className="sidebar-nav">
|
||||
{/* 工作台:独立置顶,不属于任何分组 */}
|
||||
<NavLink aria-label="工作台" title="工作台" to="/" end className={navClass}>
|
||||
<span className="icon"><DashboardIcon size={18} /></span>
|
||||
{!collapsed && <span>工作台</span>}
|
||||
</NavLink>
|
||||
|
||||
{NAV_GROUPS.map(group => {
|
||||
/* 侧栏整体收起时忽略分组的折叠状态,保证图标仍可点 */
|
||||
const open = collapsed || !collapsedGroups.includes(group.id)
|
||||
return (
|
||||
<div className="nav-group" key={group.id}>
|
||||
<div className="nav-group-header">
|
||||
<span className="nav-group-title">{group.title}</span>
|
||||
{!collapsed && (
|
||||
<button
|
||||
type="button"
|
||||
className={`nav-group-toggle${open ? ' open' : ''}`}
|
||||
onClick={() => toggleGroup(group.id)}
|
||||
aria-expanded={open}
|
||||
aria-label={`${open ? '收起' : '展开'}${group.title}`}
|
||||
title={`${open ? '收起' : '展开'}${group.title}`}
|
||||
>
|
||||
<ChevronDownIcon size={14} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{open && group.items.map(item => {
|
||||
const ItemIcon = item.icon
|
||||
return (
|
||||
<NavLink key={item.to} aria-label={item.label} title={item.label} to={item.to} className={navClass}>
|
||||
<span className="icon"><ItemIcon size={18} /></span>
|
||||
{!collapsed && <span>{item.label}</span>}
|
||||
</NavLink>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<div className="sidebar-footer">
|
||||
<div className="user-info">管理员</div>
|
||||
<button
|
||||
className="theme-toggle-btn"
|
||||
onClick={() => setTheme(current => current === 'light' ? 'dark' : 'light')}
|
||||
title={theme === 'light' ? '切换至夜间模式' : '切换至日间模式'}
|
||||
aria-label={theme === 'light' ? '切换至夜间模式' : '切换至日间模式'}
|
||||
>
|
||||
{theme === 'light' ? <MoonIcon size={18} /> : <SunIcon size={18} />}
|
||||
</button>
|
||||
<button className="settings-btn" onClick={() => setSettingsOpen(true)} title="设置" aria-label="设置">
|
||||
<TuneIcon size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main inert={settingsOpen || assistantOpen ? "" : undefined} className="main-content">
|
||||
<GlobalHeader onOpenAssistant={() => setAssistantOpen(true)} />
|
||||
<div className="route-content">
|
||||
<Routes>
|
||||
<Route path="/" element={<Dashboard />} />
|
||||
<Route path="/quotes" element={<QuoteManagement />} />
|
||||
<Route path="/pi-contracts" element={<PiContractManagement />} />
|
||||
<Route path="/purchase-orders" element={<PurchaseOrderManagement />} />
|
||||
<Route path="/products" element={<ProductManagement />} />
|
||||
<Route path="/suppliers" element={<SupplierManagement />} />
|
||||
<Route path="/procurement/import" element={<EmbeddedSku view="import" />} />
|
||||
<Route path="/customers" element={<CustomerManagement />} />
|
||||
<Route path="/orders" element={<OrderManagement />} />
|
||||
<Route path="/prospects" element={<ProspectManagement />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<AIAssistant isOpen={assistantOpen} onClose={closeAssistant} />
|
||||
<Settings isOpen={settingsOpen} onClose={closeSettings} theme={theme} onThemeChange={setTheme} reducedMotion={reducedMotion} onReducedMotionChange={setReducedMotion} />
|
||||
</div>
|
||||
</Router>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import axios from 'axios'
|
||||
import { BotIcon, CloseIcon, SendIcon, SparkIcon } from './Icons'
|
||||
|
||||
const starterMessages = [
|
||||
{ role: 'assistant', text: '你好,我是你的供应链 AI 助手。可以帮你查看客户、产品、供应商、订单、商机和待办,也可以协助发起系统内的业务操作。' },
|
||||
]
|
||||
|
||||
function AIAssistant({ isOpen, onClose }) {
|
||||
const [messages, setMessages] = useState(starterMessages)
|
||||
const [input, setInput] = useState('')
|
||||
const [context, setContext] = useState(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const inputRef = useRef(null)
|
||||
const panelRef = useRef(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return
|
||||
inputRef.current?.focus()
|
||||
const onKeyDown = (event) => {
|
||||
if (event.key === 'Escape') onClose()
|
||||
}
|
||||
window.addEventListener('keydown', onKeyDown)
|
||||
return () => window.removeEventListener('keydown', onKeyDown)
|
||||
}, [isOpen, onClose])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return
|
||||
axios.get('/api/assistant/context').then(response => setContext(response.data)).catch(() => setContext(null))
|
||||
}, [isOpen])
|
||||
|
||||
const capabilities = useMemo(() => [
|
||||
'读取全局业务概览与实时统计',
|
||||
'检索客户、供应商、产品和订单',
|
||||
'查看商机、待办与近期活动',
|
||||
'发起已授权的新增、更新和状态操作',
|
||||
], [])
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
const sendMessage = async (text = input) => {
|
||||
const prompt = text.trim()
|
||||
if (!prompt || loading) return
|
||||
setInput('')
|
||||
setMessages(prev => [...prev, { role: 'user', text: prompt }])
|
||||
setLoading(true)
|
||||
try {
|
||||
const response = await axios.post('/api/assistant/command', { prompt })
|
||||
setMessages(prev => [...prev, { role: 'assistant', text: response.data.reply || '我已收到指令,但当前还没有可执行的结果。', meta: response.data.meta }])
|
||||
if (response.data.context) setContext(response.data.context)
|
||||
} catch {
|
||||
setMessages(prev => [...prev, { role: 'assistant', text: '暂时无法连接系统能力服务,请确认后端服务正在运行。' }])
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="assistant-layer" onClick={onClose}>
|
||||
<aside ref={panelRef} className="ai-assistant-panel" role="dialog" aria-modal="true" aria-labelledby="ai-assistant-title" onClick={event => event.stopPropagation()}>
|
||||
<div className="ai-panel-header">
|
||||
<div className="ai-panel-title"><span className="ai-orb"><BotIcon size={20} /></span><div><span className="section-label">[SYSTEM COPILOT]</span><h2 id="ai-assistant-title">AI 助手</h2></div></div>
|
||||
<button className="ai-close-btn" onClick={onClose} aria-label="关闭 AI 助手"><CloseIcon size={18} /></button>
|
||||
</div>
|
||||
|
||||
<div className="ai-access-banner"><span className="ai-live-dot" /><div><strong>已连接系统业务层</strong><small>可读取模块数据,敏感写操作会先请求确认</small></div></div>
|
||||
|
||||
<div className="ai-panel-body">
|
||||
<div className="ai-capability-card"><div className="ai-capability-heading"><SparkIcon size={15} />我能帮你</div>{capabilities.map(capability => <div className="ai-capability-row" key={capability}><span>✓</span>{capability}</div>)}</div>
|
||||
{context && <div className="ai-context-strip"><span>当前数据快照</span><b>{context.stats?.customers || 0} 客户</b><b>{context.stats?.products || 0} 产品</b><b>{context.stats?.orders || 0} 订单</b></div>}
|
||||
<div className="ai-message-list">
|
||||
{messages.map((message, index) => <div className={`ai-message ${message.role}`} key={`${message.role}-${index}`}><div className="ai-message-avatar">{message.role === 'assistant' ? <BotIcon size={15} /> : 'W'}</div><div className="ai-message-content"><p>{message.text}</p>{message.meta && <small>{message.meta}</small>}</div></div>)}
|
||||
{loading && <div className="ai-message assistant"><div className="ai-message-avatar"><BotIcon size={15} /></div><div className="ai-message-content ai-thinking"><span /><span /><span /></div></div>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="ai-suggestion-row"><button onClick={() => sendMessage('汇总今天最重要的事项')}>汇总今天事项</button><button onClick={() => sendMessage('查看系统数据概览')}>查看系统概览</button></div>
|
||||
<form className="ai-input-row" onSubmit={event => { event.preventDefault(); sendMessage() }}><input ref={inputRef} value={input} onChange={event => setInput(event.target.value)} placeholder="告诉 AI 你要查什么或做什么…" aria-label="输入给 AI 助手的内容" /><button type="submit" disabled={!input.trim() || loading} aria-label="发送"><SendIcon size={18} /></button></form>
|
||||
<div className="ai-panel-footer">AI 会先说明将要执行的操作,再进行需要写入数据的动作。</div>
|
||||
</aside>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default AIAssistant
|
||||
@@ -0,0 +1,67 @@
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import axios from 'axios'
|
||||
import { BotIcon, ClockIcon, PlusCircleIcon, SearchIcon } from './Icons'
|
||||
|
||||
function GlobalHeader({ onOpenAssistant }) {
|
||||
const [now, setNow] = useState(new Date())
|
||||
const [searchOpen, setSearchOpen] = useState(false)
|
||||
const [query, setQuery] = useState('')
|
||||
const [results, setResults] = useState([])
|
||||
const [searching, setSearching] = useState(false)
|
||||
const searchRef = useRef(null)
|
||||
const navigate = useNavigate()
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setInterval(() => setNow(new Date()), 1000)
|
||||
return () => window.clearInterval(timer)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const keyword = query.trim()
|
||||
if (!keyword) { setResults([]); setSearching(false); return }
|
||||
const timer = window.setTimeout(async () => {
|
||||
setSearching(true)
|
||||
try {
|
||||
const response = await axios.get('/api/search', { params: { q: keyword } })
|
||||
setResults(response.data.results || [])
|
||||
} catch { setResults([]) } finally { setSearching(false) }
|
||||
}, 180)
|
||||
return () => window.clearTimeout(timer)
|
||||
}, [query])
|
||||
|
||||
useEffect(() => {
|
||||
const onKeyDown = event => {
|
||||
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'k') {
|
||||
event.preventDefault()
|
||||
setSearchOpen(true)
|
||||
window.setTimeout(() => searchRef.current?.focus(), 0)
|
||||
}
|
||||
if (event.key === 'Escape') { setSearchOpen(false); searchRef.current?.blur() }
|
||||
}
|
||||
window.addEventListener('keydown', onKeyDown)
|
||||
return () => window.removeEventListener('keydown', onKeyDown)
|
||||
}, [])
|
||||
|
||||
const timeLabel = useMemo(() => now.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', second: '2-digit' }), [now])
|
||||
const dateLabel = useMemo(() => now.toLocaleDateString('zh-CN', { month: 'long', day: 'numeric', weekday: 'short' }), [now])
|
||||
const selectResult = result => { navigate(result.path); setQuery(''); setSearchOpen(false) }
|
||||
|
||||
return (
|
||||
<header className="global-header">
|
||||
<div className="global-header-brand"><span className="header-status-dot" /><div><strong>happy出口通</strong><span>EXPORT OPERATIONS CONSOLE</span></div></div>
|
||||
<div className={`global-search ${searchOpen ? 'is-open' : ''}`}>
|
||||
<SearchIcon size={17} />
|
||||
<input ref={searchRef} aria-label="搜索 everything" value={query} onFocus={() => setSearchOpen(true)} onChange={event => setQuery(event.target.value)} placeholder="搜索 everything" autoComplete="off" />
|
||||
<kbd>⌘ K</kbd>
|
||||
{searchOpen && query.trim() && <div className="global-search-results" role="listbox" aria-label="全局搜索结果">
|
||||
{searching ? <div className="global-search-status">正在检索系统业务数据…</div> : results.length ? results.map(result => <button key={`${result.type}-${result.id}`} role="option" onMouseDown={event => event.preventDefault()} onClick={() => selectResult(result)}><span className={`search-result-mark ${result.type}`}>{result.typeLabel}</span><span><strong>{result.title}</strong><small>{result.detail}</small></span></button>) : <div className="global-search-status">未找到匹配的数据</div>}
|
||||
<div className="global-search-footnote">已检索客户、供应商、产品、订单、商机、待办和活动记录</div>
|
||||
</div>}
|
||||
</div>
|
||||
<div className="global-header-actions"><div className="header-clock"><ClockIcon size={16} /><span>{dateLabel}</span><b>{timeLabel}</b></div><button className="quick-create-btn" title="快速新建"><PlusCircleIcon size={19} /><span>快速新建</span></button><button className="ai-entry-btn" onClick={onOpenAssistant} title="打开 AI 助手"><BotIcon size={19} /><span>AI 助手</span><i>β</i></button><button className="header-avatar" title="当前用户">W</button><button className="mobile-search-btn" onClick={() => { setSearchOpen(v => !v); window.setTimeout(() => searchRef.current?.focus(), 0) }} title="搜索"><SearchIcon size={18} /></button></div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
|
||||
export default GlobalHeader
|
||||
@@ -0,0 +1,271 @@
|
||||
import React from 'react'
|
||||
|
||||
// SVG图标组件库
|
||||
export const DashboardIcon = ({ size = 20, className = '' }) => (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}>
|
||||
<rect x="3" y="3" width="7" height="7" />
|
||||
<rect x="14" y="3" width="7" height="7" />
|
||||
<rect x="14" y="14" width="7" height="7" />
|
||||
<rect x="3" y="14" width="7" height="7" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
/* 供应商:厂房 + 烟囱(照 icons8-厂-parakeet-line 重绘为矢量,跟随主题变色) */
|
||||
export const FactoryIcon = ({ size = 20, className = '' }) => (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" className={className} aria-hidden="true">
|
||||
{/* 厂房主体:锯齿双峰屋顶 + 左右墙 + 底边 */}
|
||||
<path d="M2.4 12l4-3.4 3 3.4 3-3.4 3.6 3.3V21.6H2.4z" />
|
||||
{/* 烟囱:左直边 + 顶口 + 略微收窄的右斜边 */}
|
||||
<path d="M18 21.6V3.4h3.4l.6 18.2" />
|
||||
{/* 窗户:2 行 × 4 列 */}
|
||||
<path d="M4.7 14.1h1.6v1.6H4.7zM7.8 14.1h1.6v1.6H7.8zM10.9 14.1h1.6v1.6h-1.6zM14 14.1h1.6v1.6H14z" />
|
||||
<path d="M4.7 17.6h1.6v1.6H4.7zM7.8 17.6h1.6v1.6H7.8zM10.9 17.6h1.6v1.6h-1.6zM14 17.6h1.6v1.6H14z" />
|
||||
{/* 补齐烟囱下方的地面线 */}
|
||||
<path d="M16 21.6h6.2" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
export const DownloadIcon = ({ size = 20, className = '' }) => (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}>
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
||||
<polyline points="7 10 12 15 17 10" />
|
||||
<line x1="12" y1="15" x2="12" y2="3" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
export const PackageIcon = ({ size = 20, className = '' }) => (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}>
|
||||
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z" />
|
||||
<polyline points="3.27 6.96 12 12.01 20.73 6.96" />
|
||||
<line x1="12" y1="22.08" x2="12" y2="12" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
export const UsersIcon = ({ size = 20, className = '' }) => (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}>
|
||||
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
|
||||
<circle cx="9" cy="7" r="4" />
|
||||
<path d="M23 21v-2a4 4 0 0 0-3-3.87" />
|
||||
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
export const TargetIcon = ({ size = 20, className = '' }) => (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<circle cx="12" cy="12" r="6" />
|
||||
<circle cx="12" cy="12" r="2" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
export const ClipboardIcon = ({ size = 20, className = '' }) => (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}>
|
||||
<path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2" />
|
||||
<rect x="8" y="2" width="8" height="4" rx="1" ry="1" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
export const SettingsIcon = ({ size = 20, className = '' }) => (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}>
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
<path d="M12 1v6m0 6v6M5.64 5.64l4.24 4.24m4.24 4.24l4.24 4.24M1 12h6m6 0h6M5.64 18.36l4.24-4.24m4.24-4.24l4.24-4.24" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
export const DollarIcon = ({ size = 20, className = '' }) => (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}>
|
||||
<line x1="12" y1="1" x2="12" y2="23" />
|
||||
<path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
export const CheckIcon = ({ size = 20, className = '' }) => (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}>
|
||||
<polyline points="20 6 9 17 4 12" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
export const PinIcon = ({ size = 20, className = '' }) => (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}>
|
||||
<path d="M12 17v5" />
|
||||
<path d="M9 10.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24V16a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-.76a2 2 0 0 0-1.11-1.79l-1.78-.9A2 2 0 0 1 15 10.76V7a1 1 0 0 1 1-1 2 2 0 0 0 0-4H8a2 2 0 0 0 0 4 1 1 0 0 1 1 1z" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
export const CalendarIcon = ({ size = 20, className = '' }) => (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}>
|
||||
<rect x="3" y="4" width="18" height="18" rx="2" ry="2" />
|
||||
<line x1="16" y1="2" x2="16" y2="6" />
|
||||
<line x1="8" y1="2" x2="8" y2="6" />
|
||||
<line x1="3" y1="10" x2="21" y2="10" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
export const BellIcon = ({ size = 20, className = '' }) => (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}>
|
||||
<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9" />
|
||||
<path d="M13.73 21a2 2 0 0 1-3.46 0" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
export const FlagIcon = ({ size = 20, className = '' }) => (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}>
|
||||
<path d="M4 15s1-1 4-1 5 2 8 2 4-1 4-1V3s-1 1-4 1-5-2-8-2-4 1-4 1z" />
|
||||
<line x1="4" y1="22" x2="4" y2="15" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
export const TuneIcon = ({ size = 20, className = '' }) => (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" className={className} aria-hidden="true">
|
||||
<path d="M4 6h16M4 12h16M4 18h16" />
|
||||
<circle cx="9" cy="6" r="2" fill="currentColor" stroke="none" />
|
||||
<circle cx="15" cy="12" r="2" fill="currentColor" stroke="none" />
|
||||
<circle cx="10" cy="18" r="2" fill="currentColor" stroke="none" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
export const SunIcon = ({ size = 20, className = '' }) => (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" className={className} aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="4" />
|
||||
<path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
export const MoonIcon = ({ size = 20, className = '' }) => (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" className={className} aria-hidden="true">
|
||||
<path d="M12 2.5C6.7 2.5 2.5 6.8 2.5 12s4.2 9.5 9.5 9.5c-3.4-2-5.6-5.6-5.6-9.5S8.6 4.5 12 2.5Z" fill="currentColor" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
|
||||
export const CloseIcon = ({ size = 20 }) => (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" aria-hidden="true"><path d="m6 6 12 12M6 18 18 6" /></svg>
|
||||
)
|
||||
export const ExchangeIcon = ({ size = 20 }) => (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M4 7h16m-4-4 4 4-4 4M20 17H4m4-4-4 4 4 4" /></svg>
|
||||
)
|
||||
export const SparkIcon = ({ size = 20 }) => (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="m12 3 2.6 6.4L21 12l-6.4 2.6L12 21l-2.6-6.4L3 12l6.4-2.6ZM20 2v4m-2-2h4" /></svg>
|
||||
)
|
||||
|
||||
export const SearchIcon = ({ size = 20, className = '' }) => (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" className={className} aria-hidden="true"><circle cx="11" cy="11" r="7" /><path d="m20 20-4-4" /></svg>
|
||||
)
|
||||
export const ClockIcon = ({ size = 20, className = '' }) => (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" className={className} aria-hidden="true"><circle cx="12" cy="12" r="9" /><path d="M12 7v5l3 2" /></svg>
|
||||
)
|
||||
export const PlusCircleIcon = ({ size = 20, className = '' }) => (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" className={className} aria-hidden="true"><circle cx="12" cy="12" r="9" /><path d="M12 8v8M8 12h8" /></svg>
|
||||
)
|
||||
export const BotIcon = ({ size = 20, className = '' }) => (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" className={className} aria-hidden="true"><rect x="4" y="7" width="16" height="12" rx="3" /><path d="M12 3v4M8 12h.01M16 12h.01M8 16h8" /><path d="M2 12h2M20 12h2" /></svg>
|
||||
)
|
||||
export const SendIcon = ({ size = 20, className = '' }) => (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" className={className} aria-hidden="true"><path d="m22 2-7 20-4-9-9-4Z" /><path d="M22 2 11 13" /></svg>
|
||||
)
|
||||
|
||||
export const ActivityIcon = ({ size = 20, className = '' }) => (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" className={className} aria-hidden="true"><path d="M4 17V9M9 17V5M14 17v-3M19 17V7" /><path d="M3 20h18" /></svg>
|
||||
)
|
||||
|
||||
|
||||
export const SidebarLayoutIcon = ({ size = 20, className = '' }) => (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.65" strokeLinecap="round" strokeLinejoin="round" className={className} aria-hidden="true">
|
||||
<rect x="3" y="3" width="18" height="18" rx="4" />
|
||||
<path d="M9 4v16" />
|
||||
<path d="M6.4 8h.1M6.4 12h.1M6.4 16h.1" strokeWidth="2.4" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
/* 分组折叠箭头:展开时朝下,收起时由 CSS 旋转 -90° */
|
||||
export const ChevronDownIcon = ({ size = 16, className = '' }) => (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" className={className} aria-hidden="true">
|
||||
<path d="m6 9 6 6 6-6" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
/* 客户报价单 */
|
||||
export const QuoteIcon = ({ size = 20, className = '' }) => (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" className={className} aria-hidden="true">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
||||
<polyline points="14 2 14 8 20 8" />
|
||||
<path d="M12 11v8" />
|
||||
<path d="M14 12.4h-2.9a1.5 1.5 0 0 0 0 3h1.8a1.5 1.5 0 0 1 0 3H10" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
/* PI 合同(形式发票 / 合同文件) */
|
||||
export const ContractIcon = ({ size = 20, className = '' }) => (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" className={className} aria-hidden="true">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
||||
<polyline points="14 2 14 8 20 8" />
|
||||
<path d="M8 13h4M8 17h6" />
|
||||
<path d="M15.5 16.2c1-.9 2.2.9 3.2 0" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
/* 采购单 */
|
||||
export const PurchaseIcon = ({ size = 20, className = '' }) => (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" className={className} aria-hidden="true">
|
||||
<path d="M2 3h2.4l2.2 11.1a2 2 0 0 0 2 1.6h8.4a2 2 0 0 0 2-1.6L21 6.6H5.2" />
|
||||
<circle cx="9.5" cy="20" r="1.6" />
|
||||
<circle cx="17.5" cy="20" r="1.6" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
/* 卡片网格排列 */
|
||||
export const GridIcon = ({ size = 18, className = '' }) => (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" className={className} aria-hidden="true">
|
||||
<rect x="3" y="3" width="7.5" height="7.5" rx="2" />
|
||||
<rect x="13.5" y="3" width="7.5" height="7.5" rx="2" />
|
||||
<rect x="3" y="13.5" width="7.5" height="7.5" rx="2" />
|
||||
<rect x="13.5" y="13.5" width="7.5" height="7.5" rx="2" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
/* 横列表排列 */
|
||||
export const ListIcon = ({ size = 18, className = '' }) => (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" className={className} aria-hidden="true">
|
||||
<path d="M8 6h13M8 12h13M8 18h13" />
|
||||
<circle cx="3.6" cy="6" r="1.2" />
|
||||
<circle cx="3.6" cy="12" r="1.2" />
|
||||
<circle cx="3.6" cy="18" r="1.2" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
/* 小推车 / 报价篮 */
|
||||
export const CartIcon = ({ size = 18, className = '' }) => (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" className={className} aria-hidden="true">
|
||||
<path d="M2 3h2l2.6 12.4a1.6 1.6 0 0 0 1.6 1.3h9.2a1.6 1.6 0 0 0 1.6-1.3L21 7H5.3" />
|
||||
<circle cx="9.5" cy="20.2" r="1.5" />
|
||||
<circle cx="17.5" cy="20.2" r="1.5" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
/* 图片占位 */
|
||||
export const ImageIcon = ({ size = 18, className = '' }) => (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" className={className} aria-hidden="true">
|
||||
<rect x="3" y="3" width="18" height="18" rx="3" />
|
||||
<circle cx="8.6" cy="8.6" r="1.7" />
|
||||
<path d="m4 17 4.6-4.6a1.6 1.6 0 0 1 2.3 0L15 16.5" />
|
||||
<path d="m14.2 15.2 1.7-1.7a1.6 1.6 0 0 1 2.3 0L21 16.5" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
/* 删除 / 回收 */
|
||||
export const TrashIcon = ({ size = 16, className = '' }) => (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" className={className} aria-hidden="true">
|
||||
<path d="M3.5 6h17" />
|
||||
<path d="M8.5 6V4.2A1.2 1.2 0 0 1 9.7 3h4.6a1.2 1.2 0 0 1 1.2 1.2V6" />
|
||||
<path d="M6 6l1 14a2 2 0 0 0 2 1.8h6A2 2 0 0 0 17 20l1-14" />
|
||||
<path d="M10.2 10.5v6M13.8 10.5v6" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
/* 铅笔 / 编辑 */
|
||||
export const PencilIcon = ({ size = 16, className = '' }) => (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" className={className} aria-hidden="true">
|
||||
<path d="M16.5 3.7a2.1 2.1 0 0 1 3 3L7.4 18.8l-4 1 1-4z" />
|
||||
<path d="m14.7 5.5 3.8 3.8" />
|
||||
</svg>
|
||||
)
|
||||
@@ -0,0 +1,130 @@
|
||||
import React, { useState, useEffect, useRef } from 'react'
|
||||
import { MoonIcon, SunIcon, TuneIcon, CheckIcon, CloseIcon, ExchangeIcon, SparkIcon } from './Icons'
|
||||
|
||||
function Settings({ isOpen, onClose, theme, onThemeChange, reducedMotion, onReducedMotionChange }) {
|
||||
const [activeCategory, setActiveCategory] = useState('appearance')
|
||||
|
||||
const modalRef = useRef(null)
|
||||
useEffect(() => {
|
||||
if (!isOpen) return
|
||||
const previousFocus = document.activeElement
|
||||
const previousOverflow = document.body.style.overflow
|
||||
document.body.style.overflow = 'hidden'
|
||||
const modal = modalRef.current
|
||||
const focusable = () => [...modal.querySelectorAll('button, input, select, textarea, a[href], [tabindex="0"]')].filter(el => !el.disabled && el.getClientRects().length)
|
||||
modal.querySelector('.close-btn')?.focus()
|
||||
const onKeyDown = event => {
|
||||
if (event.key === 'Escape') { event.preventDefault(); onClose() }
|
||||
if (event.key === 'Tab') {
|
||||
const items = focusable()
|
||||
const first = items[0], last = items[items.length - 1]
|
||||
if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last?.focus() }
|
||||
else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first?.focus() }
|
||||
}
|
||||
}
|
||||
modal.addEventListener('keydown', onKeyDown)
|
||||
return () => {
|
||||
document.body.style.overflow = previousOverflow
|
||||
modal.removeEventListener('keydown', onKeyDown)
|
||||
previousFocus?.focus()
|
||||
}
|
||||
}, [isOpen, onClose])
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
return (
|
||||
<div className="settings-overlay" onClick={onClose}>
|
||||
<div ref={modalRef} role="dialog" aria-modal="true" aria-labelledby="settings-title" className="settings-modal luxury-settings" onClick={(e) => e.stopPropagation()}>
|
||||
<aside className="settings-sidebar">
|
||||
<div className="settings-header">
|
||||
<div>
|
||||
<span className="section-label">[CONTROL CENTER]</span>
|
||||
<h2 id="settings-title">系统设置</h2>
|
||||
</div>
|
||||
<button className="close-btn" onClick={onClose} aria-label="关闭设置"><CloseIcon size={18} /></button>
|
||||
</div>
|
||||
<nav className="settings-nav" aria-label="设置分类">
|
||||
<button className={`settings-nav-item ${activeCategory === 'appearance' ? 'active' : ''}`} onClick={() => setActiveCategory('appearance')}>
|
||||
<span className="settings-nav-icon"><TuneIcon size={16} /></span>
|
||||
<span>外观与主题</span>
|
||||
</button>
|
||||
<button className={`settings-nav-item ${activeCategory === 'import-export' ? 'active' : ''}`} onClick={() => setActiveCategory('import-export')}>
|
||||
<span className="settings-nav-icon"><ExchangeIcon size={18} /></span>
|
||||
<span>数据交换</span>
|
||||
</button>
|
||||
<button className={`settings-nav-item ${activeCategory === 'ai-config' ? 'active' : ''}`} onClick={() => setActiveCategory('ai-config')}>
|
||||
<span className="settings-nav-icon"><SparkIcon size={18} /></span>
|
||||
<span>智能配置</span>
|
||||
</button>
|
||||
</nav>
|
||||
<div className="settings-sidebar-status"><TuneIcon size={15} /><span>专注业务,自在掌控<small>TRADE MANAGEMENT</small></span></div>
|
||||
</aside>
|
||||
|
||||
<section className="settings-content">
|
||||
{activeCategory === 'appearance' && <AppearanceSettings theme={theme} onThemeChange={onThemeChange} reducedMotion={reducedMotion} onReducedMotionChange={onReducedMotionChange} />}
|
||||
{activeCategory === 'import-export' && <ImportExportSettings />}
|
||||
{activeCategory === 'ai-config' && <AIConfigSettings />}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AppearanceSettings({ theme, onThemeChange, reducedMotion, onReducedMotionChange }) {
|
||||
return (
|
||||
<div className="settings-section">
|
||||
<span className="section-label">[APPEARANCE]</span>
|
||||
<h3>外观与主题</h3>
|
||||
<p className="settings-description">选择适合工作环境的显示模式,设置会自动保存在本机。</p>
|
||||
|
||||
<div className="theme-selector" role="group" aria-label="界面主题">
|
||||
<button className={`theme-option ${theme === 'dark' ? 'active' : ''}`} aria-pressed={theme === 'dark'} onClick={() => onThemeChange('dark')}>
|
||||
<span className="theme-preview theme-preview-dark" aria-hidden="true"><span className="mini-sidebar"><i /><i /><i /></span><span className="mini-workspace"><MoonIcon size={18} /><span className="mini-stats"><i /><i /><i /></span><span className="mini-table"><i /><i /><i /></span></span></span>
|
||||
<span><strong>夜间控制台</strong><small>深色 · 低光环境</small></span>
|
||||
<span className="theme-check" aria-hidden="true">{theme === 'dark' && <CheckIcon size={14} />}</span>
|
||||
</button>
|
||||
<button className={`theme-option ${theme === 'light' ? 'active' : ''}`} aria-pressed={theme === 'light'} onClick={() => onThemeChange('light')}>
|
||||
<span className="theme-preview theme-preview-light" aria-hidden="true"><span className="mini-sidebar"><i /><i /><i /></span><span className="mini-workspace"><SunIcon size={18} /><span className="mini-stats"><i /><i /><i /></span><span className="mini-table"><i /><i /><i /></span></span></span>
|
||||
<span><strong>日间工作台</strong><small>淡色 · 明亮环境</small></span>
|
||||
<span className="theme-check" aria-hidden="true">{theme === 'light' && <CheckIcon size={14} />}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="settings-group">
|
||||
<h4>显示偏好</h4>
|
||||
<div className="setting-info"><span><strong>清晰优先</strong><small>两种主题均保留高对比度文字与明确的状态标识。</small></span><span className="preference-badge">默认启用</span></div>
|
||||
<label className="setting-toggle"><span><strong>减少动效</strong><small>适用于需要稳定画面的工作环境</small></span><input type="checkbox" aria-label="减少动效" checked={reducedMotion} onChange={e => onReducedMotionChange(e.target.checked)} /><i aria-hidden="true" /></label>
|
||||
</div>
|
||||
<p className="appearance-note"><CheckIcon size={14} /> 即时生效 · 自动记住您的主题与动效偏好</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ImportExportSettings() {
|
||||
return (
|
||||
<div className="settings-section">
|
||||
<span className="section-label">[DATA EXCHANGE]</span>
|
||||
<h3>导入 / 导出</h3>
|
||||
<p className="settings-description">管理业务数据的导入、导出与本地备份。</p>
|
||||
<div className="settings-group"><h4>导出数据</h4><div className="settings-actions"><button className="btn btn-secondary">导出供应商数据</button><button className="btn btn-secondary">导出产品数据</button><button className="btn btn-secondary">导出客户数据</button><button className="btn btn-secondary">导出订单数据</button></div></div>
|
||||
<div className="settings-group"><h4>导入数据</h4><div className="settings-actions"><label className="btn btn-secondary file-label">导入供应商数据<input type="file" accept=".csv,.xlsx" /></label><label className="btn btn-secondary file-label">导入产品数据<input type="file" accept=".csv,.xlsx" /></label><label className="btn btn-secondary file-label">导入客户数据<input type="file" accept=".csv,.xlsx" /></label></div><p className="help-text">支持 CSV 和 Excel 格式文件</p></div>
|
||||
<div className="settings-group"><h4>备份与恢复</h4><div className="settings-actions"><button className="btn btn-primary">创建完整备份</button><label className="btn btn-secondary file-label">恢复备份<input type="file" accept=".db,.backup" /></label></div><p className="help-text">建议定期备份数据库</p></div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AIConfigSettings() {
|
||||
return (
|
||||
<div className="settings-section">
|
||||
<span className="section-label">[INTELLIGENCE]</span>
|
||||
<h3>智能配置</h3>
|
||||
<p className="settings-description">配置 AI 功能相关参数与模型。</p>
|
||||
<div className="settings-group"><h4>API 密钥配置</h4><div className="form-group"><label>OpenAI API Key</label><input type="password" placeholder="sk-..." className="form-input" /></div><div className="form-group"><label>Claude API Key</label><input type="password" placeholder="sk-ant-..." className="form-input" /></div><p className="help-text">当前为配置界面预览,密钥保存和连接测试尚未接入。</p></div>
|
||||
<div className="settings-group"><h4>AI 功能开关</h4><label className="setting-toggle"><span><strong>启用合同智能解析</strong><small>自动提取合同关键字段</small></span><input type="checkbox" defaultChecked /><i /></label><label className="setting-toggle"><span><strong>启用客户画像分析</strong><small>辅助判断客户跟进优先级</small></span><input type="checkbox" defaultChecked /><i /></label><label className="setting-toggle"><span><strong>启用自动报价建议</strong><small>基于历史价格提供参考</small></span><input type="checkbox" /><i /></label></div>
|
||||
<div className="settings-group"><h4>模型选择</h4><div className="form-group"><label>OCR 模型</label><select className="form-select"><option>GPT-4 Vision</option><option>Claude 3 Opus</option><option>Claude 3 Sonnet</option></select></div><div className="form-group"><label>文本分析模型</label><select className="form-select"><option>GPT-4</option><option>Claude 3 Opus</option><option>GPT-3.5 Turbo</option></select></div></div>
|
||||
<div className="settings-actions"><button className="btn btn-primary">保存配置</button><button className="btn btn-secondary">测试连接</button></div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Settings
|
||||
@@ -0,0 +1,12 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import App from './App'
|
||||
import './styles/industrial.css'
|
||||
import './styles/index.css'
|
||||
import './styles/luxury.css'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
)
|
||||
@@ -0,0 +1,207 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import axios from 'axios'
|
||||
|
||||
function CustomerManagement() {
|
||||
const [customers, setCustomers] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [showAddModal, setShowAddModal] = useState(false)
|
||||
const [formData, setFormData] = useState({
|
||||
company: '',
|
||||
country: '',
|
||||
industry: '',
|
||||
website: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
notes: ''
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
fetchCustomers()
|
||||
}, [])
|
||||
|
||||
const fetchCustomers = async () => {
|
||||
try {
|
||||
const response = await axios.get('/api/customers')
|
||||
setCustomers(response.data)
|
||||
} catch (error) {
|
||||
console.error('获取客户列表失败:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault()
|
||||
try {
|
||||
await axios.post('/api/customers', formData)
|
||||
setShowAddModal(false)
|
||||
setFormData({
|
||||
company: '',
|
||||
country: '',
|
||||
industry: '',
|
||||
website: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
notes: ''
|
||||
})
|
||||
fetchCustomers()
|
||||
} catch (error) {
|
||||
console.error('创建客户失败:', error)
|
||||
alert('创建失败: ' + error.message)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <div className="loading">加载中...</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="page-container">
|
||||
<div className="page-header">
|
||||
<h1 className="page-title">客户管理</h1>
|
||||
<button className="btn-primary" onClick={() => setShowAddModal(true)}>
|
||||
+ 新增客户
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="table-container">
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>公司名称</th>
|
||||
<th>国家</th>
|
||||
<th>行业</th>
|
||||
<th>网站</th>
|
||||
<th>联系方式</th>
|
||||
<th>负责人</th>
|
||||
<th>状态</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{customers.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan="8" className="empty-state">暂无客户数据</td>
|
||||
</tr>
|
||||
) : (
|
||||
customers.map(customer => (
|
||||
<tr key={customer.id}>
|
||||
<td><strong>{customer.company}</strong></td>
|
||||
<td>{customer.country || '-'}</td>
|
||||
<td>{customer.industry || '-'}</td>
|
||||
<td>
|
||||
{customer.website ? (
|
||||
<a href={customer.website} target="_blank" rel="noopener noreferrer">
|
||||
{customer.website}
|
||||
</a>
|
||||
) : '-'}
|
||||
</td>
|
||||
<td>
|
||||
{customer.email && <div>{customer.email}</div>}
|
||||
{customer.phone && <div>{customer.phone}</div>}
|
||||
</td>
|
||||
<td>{customer.owner_name || '-'}</td>
|
||||
<td>
|
||||
<span className={`status-badge status-${customer.status}`}>
|
||||
{customer.status === 'active' ? '活跃' : '非活跃'}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<button className="btn-link">查看</button>
|
||||
<button className="btn-link">编辑</button>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* 新增客户模态框 */}
|
||||
{showAddModal && (
|
||||
<div className="modal-overlay" onClick={() => setShowAddModal(false)}>
|
||||
<div className="modal-content" onClick={e => e.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<h2>新增客户</h2>
|
||||
<button className="modal-close" onClick={() => setShowAddModal(false)}>×</button>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="form-group">
|
||||
<label>公司名称 *</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={formData.company}
|
||||
onChange={e => setFormData({...formData, company: e.target.value})}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label>国家</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.country}
|
||||
onChange={e => setFormData({...formData, country: e.target.value})}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>行业</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.industry}
|
||||
onChange={e => setFormData({...formData, industry: e.target.value})}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>网站</label>
|
||||
<input
|
||||
type="url"
|
||||
placeholder="https://"
|
||||
value={formData.website}
|
||||
onChange={e => setFormData({...formData, website: e.target.value})}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label>邮箱</label>
|
||||
<input
|
||||
type="email"
|
||||
value={formData.email}
|
||||
onChange={e => setFormData({...formData, email: e.target.value})}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>电话</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.phone}
|
||||
onChange={e => setFormData({...formData, phone: e.target.value})}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>备注</label>
|
||||
<textarea
|
||||
rows="3"
|
||||
value={formData.notes}
|
||||
onChange={e => setFormData({...formData, notes: e.target.value})}
|
||||
/>
|
||||
</div>
|
||||
<div className="modal-footer">
|
||||
<button type="button" className="btn-secondary" onClick={() => setShowAddModal(false)}>
|
||||
取消
|
||||
</button>
|
||||
<button type="submit" className="btn-primary">
|
||||
保存
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default CustomerManagement
|
||||
@@ -0,0 +1,125 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react'
|
||||
import axios from 'axios'
|
||||
import {
|
||||
ActivityIcon,
|
||||
BellIcon,
|
||||
CalendarIcon,
|
||||
CheckIcon,
|
||||
ClipboardIcon,
|
||||
DollarIcon,
|
||||
FactoryIcon,
|
||||
FlagIcon,
|
||||
PackageIcon,
|
||||
PinIcon,
|
||||
SparkIcon,
|
||||
UsersIcon
|
||||
} from '../components/Icons'
|
||||
|
||||
const WEEKDAYS = ['周一', '周二', '周三', '周四', '周五', '周六', '周日']
|
||||
|
||||
function toDateKey(date) {
|
||||
const year = date.getFullYear()
|
||||
const month = `${date.getMonth() + 1}`.padStart(2, '0')
|
||||
const day = `${date.getDate()}`.padStart(2, '0')
|
||||
return `${year}-${month}-${day}`
|
||||
}
|
||||
|
||||
function CalendarBoard({ todos }) {
|
||||
const today = new Date()
|
||||
const [month, setMonth] = useState(new Date(today.getFullYear(), today.getMonth(), 1))
|
||||
const monthLabel = month.toLocaleDateString('zh-CN', { year: 'numeric', month: 'long' })
|
||||
const monthStartDay = (month.getDay() + 6) % 7
|
||||
const daysInMonth = new Date(month.getFullYear(), month.getMonth() + 1, 0).getDate()
|
||||
const previousDays = new Date(month.getFullYear(), month.getMonth(), 0).getDate()
|
||||
|
||||
const calendarDays = useMemo(() => {
|
||||
return Array.from({ length: 42 }, (_, index) => {
|
||||
const offset = index - monthStartDay + 1
|
||||
const date = new Date(month.getFullYear(), month.getMonth(), offset)
|
||||
return { date, currentMonth: date.getMonth() === month.getMonth(), key: toDateKey(date) }
|
||||
})
|
||||
}, [month, monthStartDay])
|
||||
|
||||
const events = useMemo(() => {
|
||||
const result = {}
|
||||
todos.forEach(todo => {
|
||||
if (!todo.due_date) return
|
||||
const key = todo.due_date.slice(0, 10)
|
||||
result[key] = [...(result[key] || []), { label: todo.title, tone: todo.priority === 'high' ? 'danger' : 'blue' }]
|
||||
})
|
||||
const year = month.getFullYear()
|
||||
const holidays = [
|
||||
{ date: `${year}-01-01`, label: '元旦 · 节日', tone: 'holiday' },
|
||||
{ date: `${year}-05-01`, label: '劳动节 · 节日', tone: 'holiday' },
|
||||
{ date: `${year}-10-01`, label: '国庆节 · 节日', tone: 'holiday' },
|
||||
{ date: `${year}-12-25`, label: '圣诞节 · 节日', tone: 'holiday' },
|
||||
]
|
||||
holidays.forEach(event => { result[event.date] = [...(result[event.date] || []), event] })
|
||||
return result
|
||||
}, [month, todos])
|
||||
|
||||
const shiftMonth = (delta) => setMonth(current => new Date(current.getFullYear(), current.getMonth() + delta, 1))
|
||||
const resetMonth = () => setMonth(new Date(today.getFullYear(), today.getMonth(), 1))
|
||||
|
||||
return (
|
||||
<section className="calendar-board dashboard-panel">
|
||||
<div className="dashboard-panel-header calendar-board-header">
|
||||
<div><span className="section-label">[SCHEDULE]</span><h2><CalendarIcon size={18} /> 工作日历</h2></div>
|
||||
<div className="calendar-toolbar"><button className="calendar-today-btn" onClick={resetMonth}>今天</button><button onClick={() => shiftMonth(-1)} aria-label="上个月">‹</button><strong>{monthLabel}</strong><button onClick={() => shiftMonth(1)} aria-label="下个月">›</button><button className="calendar-view-btn">月</button></div>
|
||||
</div>
|
||||
<div className="calendar-week-row">{WEEKDAYS.map(day => <div key={day}>{day}</div>)}</div>
|
||||
<div className="calendar-grid">
|
||||
{calendarDays.map(({ date, currentMonth, key }) => {
|
||||
const dayEvents = events[key] || []
|
||||
const isToday = key === toDateKey(today)
|
||||
return <div className={`calendar-day ${currentMonth ? '' : 'is-outside'} ${isToday ? 'is-today' : ''}`} key={`${key}-${date.getTime()}`}>
|
||||
<span className="calendar-day-number">{date.getDate()}</span>
|
||||
<div className="calendar-events">{dayEvents.slice(0, 3).map((event, index) => <span className={`calendar-event ${event.tone}`} key={`${event.label}-${index}`} title={event.label}>{event.label}</span>)}</div>
|
||||
</div>
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function Dashboard() {
|
||||
const [data, setData] = useState({ stats: {}, recentActivities: [], todos: [] })
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
axios.get('/api/dashboard').then(response => setData(response.data)).catch(error => console.error('获取工作台数据失败:', error)).finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
if (loading) return <div className="loading">加载工作台数据…</div>
|
||||
|
||||
const { stats, recentActivities, todos } = data
|
||||
const completion = todos.length ? Math.max(12, Math.round((todos.filter(todo => todo.status === 'completed').length / todos.length) * 100)) : 0
|
||||
|
||||
return (
|
||||
<div className="dashboard-container">
|
||||
<div className="dashboard-welcome"><div><span className="section-label">[WORKSPACE / OVERVIEW]</span><h1 className="page-title">工作台</h1><p>把今天的节奏、客户和订单放在同一个视野里。</p></div><div className="dashboard-date-chip"><span>运行日期</span><strong>{new Date().toLocaleDateString('zh-CN', { year: 'numeric', month: 'long', day: 'numeric' })}</strong></div></div>
|
||||
|
||||
<CalendarBoard todos={todos} />
|
||||
|
||||
<div className="dashboard-content-grid">
|
||||
<div className="dashboard-primary-column">
|
||||
<section className="dashboard-panel ai-digest-panel"><div className="dashboard-panel-header"><div><span className="section-label">[AI BRIEFING]</span><h2><SparkIcon size={16} /> 今日工作摘要</h2></div><span className="panel-live-status"><i />实时</span></div><div className="ai-digest-body"><strong>系统已准备好协助你处理今天的业务。</strong><p>当前共有 <b>{stats.pendingTodos || 0}</b> 项待办、<b>{stats.activeDeals || 0}</b> 个进行中商机和 <b>{stats.orders || 0}</b> 笔进行中订单。打开右上角 AI 助手,可以直接用自然语言查阅和操作系统。</p><div className="digest-tags"><span>供应链</span><span>客户跟进</span><span>订单节奏</span></div></div></section>
|
||||
|
||||
<section className="dashboard-panel"><div className="dashboard-panel-header"><div><span className="section-label">[TASK QUEUE]</span><h2><PinIcon size={17} /> 待办事项</h2></div><button className="panel-link">查看全部</button></div>{todos.length === 0 ? <div className="dashboard-empty">暂无待办事项,今天可以保持从容。</div> : <ul className="todo-list">{todos.map(todo => <li key={todo.id} className="todo-item"><input type="checkbox" aria-label={`完成 ${todo.title}`} /><div className="todo-content"><div className="todo-title">{todo.title}</div><div className="todo-meta">{todo.customer_name ? `客户:${todo.customer_name}` : '内部任务'}{todo.due_date ? ` · 截止 ${new Date(todo.due_date).toLocaleDateString('zh-CN')}` : ''}</div></div><span className={`priority-badge priority-${todo.priority}`}>{todo.priority === 'high' ? '高' : todo.priority === 'medium' ? '中' : '低'}</span></li>)}</ul>}</section>
|
||||
|
||||
<section className="dashboard-panel"><div className="dashboard-panel-header"><div><span className="section-label">[ACTIVITY LOG]</span><h2><ActivityIcon size={17} /> 最近活动</h2></div><button className="panel-link">查看日志</button></div>{recentActivities.length === 0 ? <div className="dashboard-empty">暂无最近活动</div> : <ul className="activity-list">{recentActivities.map(activity => <li key={activity.id} className="activity-item"><span className="activity-time">{new Date(activity.created_at).toLocaleString('zh-CN')}</span><div className="activity-content"><strong>{activity.subject}</strong><span>{activity.content}</span></div></li>)}</ul>}</section>
|
||||
</div>
|
||||
|
||||
<aside className="dashboard-secondary-column">
|
||||
<section className="dashboard-panel stat-rail"><div className="dashboard-panel-header"><div><span className="section-label">[SYSTEM PULSE]</span><h2>业务概览</h2></div><span className="panel-period">本月</span></div><StatLine icon={<FactoryIcon size={17} />} label="供应商" value={stats.suppliers || 0} /><StatLine icon={<PackageIcon size={17} />} label="产品 SKU" value={stats.products || 0} /><StatLine icon={<UsersIcon size={17} />} label="客户" value={stats.customers || 0} /><StatLine icon={<ClipboardIcon size={17} />} label="进行中订单" value={stats.orders || 0} /><StatLine icon={<DollarIcon size={17} />} label="进行中商机" value={stats.activeDeals || 0} /></section>
|
||||
<section className="dashboard-panel completion-panel"><div className="dashboard-panel-header"><div><span className="section-label">[TASK HEALTH]</span><h2>任务完成情况</h2></div><span className="panel-period">当前</span></div><div className="completion-ring" style={{ '--progress': `${completion}%` }}><div><strong>{completion}%</strong><span>完成率</span></div></div><div className="completion-legend"><span><i className="dot done" />已完成</span><span><i className="dot pending" />待处理</span></div></section>
|
||||
<section className="dashboard-panel quick-panel"><div className="dashboard-panel-header"><div><span className="section-label">[QUICK ACCESS]</span><h2>快捷入口</h2></div></div><button><UsersIcon size={16} /> 新增客户</button><button><PackageIcon size={16} /> 添加产品</button><button><ClipboardIcon size={16} /> 新建订单</button></section>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function StatLine({ icon, label, value }) { return <div className="stat-line"><span className="stat-line-icon">{icon}</span><span>{label}</span><strong>{value}</strong></div> }
|
||||
|
||||
export default Dashboard
|
||||
@@ -0,0 +1,91 @@
|
||||
import React, { useState, useRef } from 'react'
|
||||
|
||||
/**
|
||||
* SKU 功能嵌入页(整合过渡期方案)。
|
||||
*
|
||||
* 合并策略是「先嵌后重写」:SKU 的能力先以嵌入方式跑通,
|
||||
* 之后再逐步重写为原生 React 页面。
|
||||
*
|
||||
* 现在嵌入的是独立的 sku-manager 服务(3300);等后端合并(P1/P2)完成后,
|
||||
* 把 SKU_ORIGIN 换成同源子路径(如 /sku)即可,页面组件本身不用动。
|
||||
*/
|
||||
const SKU_ORIGIN = import.meta.env.VITE_SKU_ORIGIN || 'http://127.0.0.1:3300'
|
||||
|
||||
const VIEW_META = {
|
||||
search: {
|
||||
title: '产品管理',
|
||||
sub: '按货号、品名、规格、材质或参数检索产品与采购价'
|
||||
},
|
||||
suppliers: {
|
||||
title: '供应商管理',
|
||||
sub: '供应商档案、联系方式、工厂所在地与主营类目'
|
||||
},
|
||||
import: {
|
||||
title: '合同导入',
|
||||
sub: '批量导入采购合同,自动解析价格、产品与参数'
|
||||
}
|
||||
}
|
||||
|
||||
function EmbeddedSku({ view = 'search' }) {
|
||||
const [loaded, setLoaded] = useState(false)
|
||||
const frameRef = useRef(null)
|
||||
const meta = VIEW_META[view] || VIEW_META.search
|
||||
const src = `${SKU_ORIGIN}/?embed=1&view=${encodeURIComponent(view)}`
|
||||
|
||||
return (
|
||||
<div className="page-container">
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<h1 className="page-title">{meta.title}</h1>
|
||||
<p style={{ color: 'var(--text-secondary)', fontSize: '14px', marginTop: '6px' }}>
|
||||
{meta.sub}
|
||||
</p>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
<button className="btn btn-sm" onClick={() => frameRef.current?.contentWindow?.location.reload()}>
|
||||
刷新
|
||||
</button>
|
||||
<a className="btn btn-sm" href={src} target="_blank" rel="noreferrer" style={{ textDecoration: 'none' }}>
|
||||
新窗口打开
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
position: 'relative',
|
||||
height: 'calc(100vh - 210px)',
|
||||
minHeight: '560px',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 'var(--radius-lg)',
|
||||
overflow: 'hidden',
|
||||
background: 'var(--surface)'
|
||||
}}
|
||||
>
|
||||
{!loaded && (
|
||||
<div className="loading" style={{ position: 'absolute', inset: 0, zIndex: 1 }}>
|
||||
<div className="spinner"></div>
|
||||
<span style={{ marginLeft: '12px' }}>正在加载 SKU 模块…</span>
|
||||
</div>
|
||||
)}
|
||||
<iframe
|
||||
ref={frameRef}
|
||||
title={meta.title}
|
||||
src={src}
|
||||
onLoad={() => setLoaded(true)}
|
||||
allow="clipboard-write"
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
border: 0,
|
||||
display: 'block',
|
||||
opacity: loaded ? 1 : 0,
|
||||
transition: 'opacity .18s ease'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default EmbeddedSku
|
||||
@@ -0,0 +1,99 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import axios from 'axios'
|
||||
|
||||
function OrderManagement() {
|
||||
const [orders, setOrders] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
fetchOrders()
|
||||
}, [])
|
||||
|
||||
const fetchOrders = async () => {
|
||||
try {
|
||||
const response = await axios.get('/api/orders')
|
||||
setOrders(response.data)
|
||||
} catch (error) {
|
||||
console.error('获取订单列表失败:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const getStatusText = (status) => {
|
||||
const statusMap = {
|
||||
draft: '草稿',
|
||||
confirmed: '已确认',
|
||||
production: '生产中',
|
||||
shipped: '已发货',
|
||||
completed: '已完成',
|
||||
cancelled: '已取消'
|
||||
}
|
||||
return statusMap[status] || status
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <div className="loading">加载中...</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="page-container">
|
||||
<div className="page-header">
|
||||
<h1 className="page-title">订单管理</h1>
|
||||
<button className="btn-primary">+ 新建订单</button>
|
||||
</div>
|
||||
|
||||
<div className="table-container">
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>订单号</th>
|
||||
<th>客户</th>
|
||||
<th>订单日期</th>
|
||||
<th>交货日期</th>
|
||||
<th>金额</th>
|
||||
<th>状态</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{orders.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan="7" className="empty-state">暂无订单数据</td>
|
||||
</tr>
|
||||
) : (
|
||||
orders.map(order => (
|
||||
<tr key={order.id}>
|
||||
<td><strong>{order.order_no}</strong></td>
|
||||
<td>{order.customer_name}</td>
|
||||
<td>{new Date(order.order_date).toLocaleDateString('zh-CN')}</td>
|
||||
<td>
|
||||
{order.delivery_date
|
||||
? new Date(order.delivery_date).toLocaleDateString('zh-CN')
|
||||
: '-'}
|
||||
</td>
|
||||
<td>
|
||||
{order.total_amount
|
||||
? `${order.currency} ${order.total_amount.toFixed(2)}`
|
||||
: '-'}
|
||||
</td>
|
||||
<td>
|
||||
<span className={`status-badge status-${order.status}`}>
|
||||
{getStatusText(order.status)}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<button className="btn-link">查看</button>
|
||||
<button className="btn-link">编辑</button>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default OrderManagement
|
||||
@@ -0,0 +1,96 @@
|
||||
import React, { useState, useEffect, useMemo } from 'react'
|
||||
import axios from 'axios'
|
||||
|
||||
/**
|
||||
* PI 合同管理(Proforma Invoice)
|
||||
* 数据来源:happy 后端 /api/pi-contracts(P1 后端合并阶段开放)
|
||||
*/
|
||||
function PiContractManagement() {
|
||||
const [contracts, setContracts] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [keyword, setKeyword] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true
|
||||
axios.get('/api/pi-contracts')
|
||||
.then(res => { if (alive) setContracts(Array.isArray(res.data) ? res.data : res.data?.items || []) })
|
||||
.catch(() => { if (alive) setContracts([]) })
|
||||
.finally(() => { if (alive) setLoading(false) })
|
||||
return () => { alive = false }
|
||||
}, [])
|
||||
|
||||
const rows = useMemo(() => {
|
||||
const kw = keyword.trim().toLowerCase()
|
||||
if (!kw) return contracts
|
||||
return contracts.filter(c => [c.pi_no, c.customer_name, c.customer_company].filter(Boolean).join(' ').toLowerCase().includes(kw))
|
||||
}, [contracts, keyword])
|
||||
|
||||
const statusText = (s) => ({ draft: '草稿', signed: '已签署', deposited: '已收定金', producing: '生产中', shipped: '已出运', closed: '已结案' }[s] || s || '—')
|
||||
|
||||
return (
|
||||
<div className="page-container">
|
||||
<div className="page-header">
|
||||
<h1 className="page-title">PI 合同管理</h1>
|
||||
<button className="btn btn-primary">+ 新建 PI</button>
|
||||
</div>
|
||||
|
||||
<div className="toolbar">
|
||||
<input
|
||||
type="text"
|
||||
className="toolbar-search"
|
||||
placeholder="搜索 PI 编号 / 客户"
|
||||
value={keyword}
|
||||
onChange={e => setKeyword(e.target.value)}
|
||||
/>
|
||||
<div className="toolbar-actions">
|
||||
<span className="secondary">{rows.length} 条</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="table-container">
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>PI 编号</th>
|
||||
<th>客户</th>
|
||||
<th>签订日期</th>
|
||||
<th>金额</th>
|
||||
<th>收款条件</th>
|
||||
<th>状态</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading ? (
|
||||
<tr><td colSpan="7" className="empty-state">加载中...</td></tr>
|
||||
) : rows.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan="7" className="empty-state">
|
||||
<div className="empty-state-title">暂无 PI 合同</div>
|
||||
<div className="empty-state-hint">PI 合同接口待接入(P1 后端合并阶段开放)</div>
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
rows.map(c => (
|
||||
<tr key={c.id}>
|
||||
<td><strong>{c.pi_no}</strong></td>
|
||||
<td>{c.customer_name || c.customer_company || '—'}</td>
|
||||
<td>{c.signed_date ? new Date(c.signed_date).toLocaleDateString('zh-CN') : '—'}</td>
|
||||
<td>{c.total_amount ? `${c.currency || 'USD'} ${Number(c.total_amount).toFixed(2)}` : '—'}</td>
|
||||
<td>{c.payment_terms || '—'}</td>
|
||||
<td><span className={`status-badge status-${c.status}`}>{statusText(c.status)}</span></td>
|
||||
<td>
|
||||
<button className="btn-link">查看</button>
|
||||
<button className="btn-link">生成单据</button>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default PiContractManagement
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,108 @@
|
||||
import React, { useState } from 'react'
|
||||
|
||||
function ProspectManagement() {
|
||||
const [formData, setFormData] = useState({
|
||||
productKeywords: '',
|
||||
countries: '',
|
||||
customerTypes: '',
|
||||
excludeKeywords: '',
|
||||
resultLimit: 100
|
||||
})
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault()
|
||||
alert('自动获客功能开发中...')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="page-container">
|
||||
<div className="page-header">
|
||||
<h1 className="page-title">新客开发(自动获客)</h1>
|
||||
</div>
|
||||
|
||||
<div className="prospect-container">
|
||||
{/* 搜索条件 */}
|
||||
<div className="card">
|
||||
<h2>搜索条件</h2>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="form-group">
|
||||
<label>产品关键词</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="如:LED灯具, 五金工具"
|
||||
value={formData.productKeywords}
|
||||
onChange={e => setFormData({...formData, productKeywords: e.target.value})}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>目标国家</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="如:德国, 英国, 美国"
|
||||
value={formData.countries}
|
||||
onChange={e => setFormData({...formData, countries: e.target.value})}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>客户类型</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="如:经销商, 系统集成商, OEM"
|
||||
value={formData.customerTypes}
|
||||
onChange={e => setFormData({...formData, customerTypes: e.target.value})}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>排除关键词</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="如:招聘, 二手, 学校"
|
||||
value={formData.excludeKeywords}
|
||||
onChange={e => setFormData({...formData, excludeKeywords: e.target.value})}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>结果数量</label>
|
||||
<input
|
||||
type="number"
|
||||
value={formData.resultLimit}
|
||||
onChange={e => setFormData({...formData, resultLimit: e.target.value})}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button type="submit" className="btn-primary">
|
||||
开始搜索
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* 搜索结果 */}
|
||||
<div className="card">
|
||||
<h2>搜索结果</h2>
|
||||
<div className="empty-state">
|
||||
请配置搜索条件并点击"开始搜索"
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="info-box">
|
||||
<h3>功能说明</h3>
|
||||
<p>自动获客功能将帮助你从多个数据源自动搜索潜在客户:</p>
|
||||
<ul>
|
||||
<li>公开公司注册数据(GLEIF、Companies House)</li>
|
||||
<li>网页搜索引擎</li>
|
||||
<li>AI 智能解析和评分</li>
|
||||
<li>自动去重和质量过滤</li>
|
||||
</ul>
|
||||
<p><strong>此功能将在后续版本中完善。</strong></p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ProspectManagement
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import React, { useState, useEffect, useMemo } from 'react'
|
||||
import axios from 'axios'
|
||||
|
||||
/**
|
||||
* 采购单管理
|
||||
* 数据来源:happy 后端 /api/purchase-orders(P1 后端合并阶段开放)
|
||||
*/
|
||||
function PurchaseOrderManagement() {
|
||||
const [orders, setOrders] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [keyword, setKeyword] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true
|
||||
axios.get('/api/purchase-orders')
|
||||
.then(res => { if (alive) setOrders(Array.isArray(res.data) ? res.data : res.data?.items || []) })
|
||||
.catch(() => { if (alive) setOrders([]) })
|
||||
.finally(() => { if (alive) setLoading(false) })
|
||||
return () => { alive = false }
|
||||
}, [])
|
||||
|
||||
const rows = useMemo(() => {
|
||||
const kw = keyword.trim().toLowerCase()
|
||||
if (!kw) return orders
|
||||
return orders.filter(o => [o.po_no, o.supplier_name].filter(Boolean).join(' ').toLowerCase().includes(kw))
|
||||
}, [orders, keyword])
|
||||
|
||||
const statusText = (s) => ({ draft: '草稿', issued: '已下达', received: '已收货', partial: '部分到货', closed: '已关闭', cancelled: '已取消' }[s] || s || '—')
|
||||
|
||||
return (
|
||||
<div className="page-container">
|
||||
<div className="page-header">
|
||||
<h1 className="page-title">采购单管理</h1>
|
||||
<button className="btn btn-primary">+ 新建采购单</button>
|
||||
</div>
|
||||
|
||||
<div className="toolbar">
|
||||
<input
|
||||
type="text"
|
||||
className="toolbar-search"
|
||||
placeholder="搜索采购单号 / 供应商"
|
||||
value={keyword}
|
||||
onChange={e => setKeyword(e.target.value)}
|
||||
/>
|
||||
<div className="toolbar-actions">
|
||||
<span className="secondary">{rows.length} 条</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="table-container">
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>采购单号</th>
|
||||
<th>供应商</th>
|
||||
<th>下单日期</th>
|
||||
<th>预计交期</th>
|
||||
<th>金额</th>
|
||||
<th>状态</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading ? (
|
||||
<tr><td colSpan="7" className="empty-state">加载中...</td></tr>
|
||||
) : rows.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan="7" className="empty-state">
|
||||
<div className="empty-state-title">暂无采购单</div>
|
||||
<div className="empty-state-hint">采购单接口待接入(P1 后端合并阶段开放)</div>
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
rows.map(o => (
|
||||
<tr key={o.id}>
|
||||
<td><strong>{o.po_no}</strong></td>
|
||||
<td>{o.supplier_name || '—'}</td>
|
||||
<td>{o.order_date ? new Date(o.order_date).toLocaleDateString('zh-CN') : '—'}</td>
|
||||
<td>{o.expected_date ? new Date(o.expected_date).toLocaleDateString('zh-CN') : '—'}</td>
|
||||
<td>{o.total_amount ? `${o.currency || 'CNY'} ${Number(o.total_amount).toFixed(2)}` : '—'}</td>
|
||||
<td><span className={`status-badge status-${o.status}`}>{statusText(o.status)}</span></td>
|
||||
<td>
|
||||
<button className="btn-link">查看</button>
|
||||
<button className="btn-link">编辑</button>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default PurchaseOrderManagement
|
||||
@@ -0,0 +1,97 @@
|
||||
import React, { useState, useEffect, useMemo } from 'react'
|
||||
import axios from 'axios'
|
||||
|
||||
/**
|
||||
* 客户报价单管理
|
||||
* 数据来源:happy 后端 /api/quotes(P1 后端合并阶段开放)
|
||||
* 未接入前保持可用的空态,不做假数据。
|
||||
*/
|
||||
function QuoteManagement() {
|
||||
const [quotes, setQuotes] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [keyword, setKeyword] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true
|
||||
axios.get('/api/quotes')
|
||||
.then(res => { if (alive) setQuotes(Array.isArray(res.data) ? res.data : res.data?.items || []) })
|
||||
.catch(() => { if (alive) setQuotes([]) })
|
||||
.finally(() => { if (alive) setLoading(false) })
|
||||
return () => { alive = false }
|
||||
}, [])
|
||||
|
||||
const rows = useMemo(() => {
|
||||
const kw = keyword.trim().toLowerCase()
|
||||
if (!kw) return quotes
|
||||
return quotes.filter(q => [q.quote_no, q.customer_name, q.customer_company].filter(Boolean).join(' ').toLowerCase().includes(kw))
|
||||
}, [quotes, keyword])
|
||||
|
||||
const statusText = (s) => ({ draft: '草稿', sent: '已发送', accepted: '已接受', rejected: '已拒绝', expired: '已过期' }[s] || s || '—')
|
||||
|
||||
return (
|
||||
<div className="page-container">
|
||||
<div className="page-header">
|
||||
<h1 className="page-title">客户报价单管理</h1>
|
||||
<button className="btn btn-primary">+ 新建报价单</button>
|
||||
</div>
|
||||
|
||||
<div className="toolbar">
|
||||
<input
|
||||
type="text"
|
||||
className="toolbar-search"
|
||||
placeholder="搜索报价单号 / 客户"
|
||||
value={keyword}
|
||||
onChange={e => setKeyword(e.target.value)}
|
||||
/>
|
||||
<div className="toolbar-actions">
|
||||
<span className="secondary">{rows.length} 条</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="table-container">
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>报价单号</th>
|
||||
<th>客户</th>
|
||||
<th>报价日期</th>
|
||||
<th>有效期至</th>
|
||||
<th>金额</th>
|
||||
<th>状态</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading ? (
|
||||
<tr><td colSpan="7" className="empty-state">加载中...</td></tr>
|
||||
) : rows.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan="7" className="empty-state">
|
||||
<div className="empty-state-title">暂无报价单</div>
|
||||
<div className="empty-state-hint">报价单接口待接入(P1 后端合并阶段开放)</div>
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
rows.map(q => (
|
||||
<tr key={q.id}>
|
||||
<td><strong>{q.quote_no}</strong></td>
|
||||
<td>{q.customer_name || q.customer_company || '—'}</td>
|
||||
<td>{q.quote_date ? new Date(q.quote_date).toLocaleDateString('zh-CN') : '—'}</td>
|
||||
<td>{q.valid_until ? new Date(q.valid_until).toLocaleDateString('zh-CN') : '—'}</td>
|
||||
<td>{q.total_amount ? `${q.currency || 'USD'} ${Number(q.total_amount).toFixed(2)}` : '—'}</td>
|
||||
<td><span className={`status-badge status-${q.status}`}>{statusText(q.status)}</span></td>
|
||||
<td>
|
||||
<button className="btn-link">查看</button>
|
||||
<button className="btn-link">转 PI</button>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default QuoteManagement
|
||||
@@ -0,0 +1,596 @@
|
||||
import React, { useState, useEffect, useMemo, useCallback } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import axios from 'axios'
|
||||
import { GridIcon, ListIcon } from '../components/Icons'
|
||||
|
||||
/**
|
||||
* 供应商管理(原生 React 版)
|
||||
*
|
||||
* 由 SKU Manager 的「供应商管理」视图迁移而来,功能对齐:
|
||||
* 卡片列表 · 未分类入口 · 新建/编辑 · 删除 · 详情(联系方式 / 供货清单 / 合同记录)。
|
||||
* 数据经 /api/sku/* 同源代理访问,禁止硬编码 SKU 的独立端口。
|
||||
*/
|
||||
|
||||
const API = '/api/sku'
|
||||
|
||||
/* ------------------------------ 格式化工具 ------------------------------ */
|
||||
|
||||
const money = (n) => (n == null ? '—' : Number(n).toFixed(2))
|
||||
|
||||
function fmtDate(iso, withTime = true) {
|
||||
if (!iso) return '—'
|
||||
const d = new Date(iso)
|
||||
const pad = (n) => String(n).padStart(2, '0')
|
||||
const base = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
|
||||
return withTime ? `${base} ${pad(d.getHours())}:${pad(d.getMinutes())}` : base
|
||||
}
|
||||
|
||||
function splitCats(text) {
|
||||
return String(text || '')
|
||||
.split(/[、,,;;/|\s]+/)
|
||||
.map((t) => t.trim())
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
/** 从完整地址里抽出省市,卡片上只显示大致方位 */
|
||||
function shortLocation(address) {
|
||||
const a = String(address || '').trim()
|
||||
if (!a) return ''
|
||||
const m = a.match(/^(.{2,4}?(?:省|市|自治区|特别行政区))?\s*(.{2,6}?(?:市|自治州|地区|县|区))?/)
|
||||
const parts = [m?.[1], m?.[2]].filter(Boolean)
|
||||
if (parts.length) return parts.join('')
|
||||
return a.length > 12 ? `${a.slice(0, 12)}…` : a
|
||||
}
|
||||
|
||||
const FIELDS = [
|
||||
['name', '供应商名称', true],
|
||||
['short_name', '简称'],
|
||||
['contact_person', '联系人'],
|
||||
['phone', '电话'],
|
||||
['email', '邮箱'],
|
||||
['website', '网址'],
|
||||
['payment_terms', '付款条件'],
|
||||
['address', '地址']
|
||||
]
|
||||
|
||||
const EMPTY_FORM = {
|
||||
name: '',
|
||||
short_name: '',
|
||||
contact_person: '',
|
||||
phone: '',
|
||||
email: '',
|
||||
website: '',
|
||||
payment_terms: '',
|
||||
address: '',
|
||||
main_categories: '',
|
||||
notes: ''
|
||||
}
|
||||
|
||||
const FILE_TYPE_LABEL = {
|
||||
excel: 'Excel',
|
||||
'pdf-text': 'PDF 文字版',
|
||||
'pdf-scan': 'PDF 扫描件',
|
||||
unknown: '无法识别'
|
||||
}
|
||||
|
||||
const STATUS_LABEL = { success: '成功', empty: '未识别到数据', failed: '失败' }
|
||||
const STATUS_PILL = { success: 'pill pill-ok', empty: 'pill pill-mute', failed: 'pill pill-warn' }
|
||||
|
||||
/* -------------------------------- 小组件 -------------------------------- */
|
||||
|
||||
function CatChips({ text }) {
|
||||
const cats = splitCats(text)
|
||||
if (!cats.length) return <div className="chip-row"><span className="muted-note">未填主营类目</span></div>
|
||||
return (
|
||||
<div className="chip-row">
|
||||
{cats.map((c) => <span className="chip" key={c}>{c}</span>)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Kv({ label, value, mono }) {
|
||||
return (
|
||||
<div className="kv">
|
||||
<span className="kv-label">{label}</span>
|
||||
<span className={`kv-value${value ? '' : ' is-empty'}`} style={mono && value ? { fontFamily: 'var(--font-mono)' } : undefined}>
|
||||
{value || '未填写'}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* -------------------------------- 主页面 -------------------------------- */
|
||||
|
||||
function SupplierManagement() {
|
||||
const navigate = useNavigate()
|
||||
|
||||
const [mode, setMode] = useState('list') // list | detail | form
|
||||
const [suppliers, setSuppliers] = useState([])
|
||||
const [unassigned, setUnassigned] = useState(0)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [notice, setNotice] = useState('')
|
||||
const [keyword, setKeyword] = useState('')
|
||||
// 排列方式:卡片网格 / 横列表。选择记在本地,下次进来自动沿用。
|
||||
const [viewMode, setViewMode] = useState(() => {
|
||||
try { return localStorage.getItem('happy.supplierView') || 'card' } catch { return 'card' }
|
||||
})
|
||||
|
||||
const changeView = useCallback((next) => {
|
||||
setViewMode(next)
|
||||
try { localStorage.setItem('happy.supplierView', next) } catch { /* 隐私模式下忽略 */ }
|
||||
}, [])
|
||||
|
||||
const [detail, setDetail] = useState(null)
|
||||
const [detailLoading, setDetailLoading] = useState(false)
|
||||
|
||||
const [form, setForm] = useState(EMPTY_FORM)
|
||||
const [editingId, setEditingId] = useState(null)
|
||||
const [editingName, setEditingName] = useState('')
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
const loadList = useCallback(async () => {
|
||||
try {
|
||||
const [listRes, unassignedRes] = await Promise.all([
|
||||
axios.get(`${API}/suppliers`),
|
||||
axios.get(`${API}/suppliers/unassigned/count`).catch(() => ({ data: { count: 0 } }))
|
||||
])
|
||||
setSuppliers(Array.isArray(listRes.data) ? listRes.data : [])
|
||||
setUnassigned(unassignedRes.data?.count || 0)
|
||||
} catch (err) {
|
||||
setNotice(err?.response?.data?.error || `加载供应商失败:${err.message}`)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => { loadList() }, [loadList])
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const kw = keyword.trim().toLowerCase()
|
||||
if (!kw) return suppliers
|
||||
return suppliers.filter((s) =>
|
||||
[s.name, s.short_name, s.contact_person, s.phone, s.address, s.main_categories]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
.toLowerCase()
|
||||
.includes(kw)
|
||||
)
|
||||
}, [suppliers, keyword])
|
||||
|
||||
const openDetail = useCallback(async (id) => {
|
||||
setMode('detail')
|
||||
setDetailLoading(true)
|
||||
setDetail(null)
|
||||
setNotice('')
|
||||
try {
|
||||
const { data } = await axios.get(`${API}/suppliers/${id}`)
|
||||
setDetail(data)
|
||||
} catch (err) {
|
||||
setNotice(err?.response?.data?.error || `加载详情失败:${err.message}`)
|
||||
} finally {
|
||||
setDetailLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const startCreate = () => {
|
||||
setForm(EMPTY_FORM)
|
||||
setEditingId(null)
|
||||
setEditingName('')
|
||||
setNotice('')
|
||||
setMode('form')
|
||||
}
|
||||
|
||||
const startEdit = (supplier) => {
|
||||
setForm({ ...EMPTY_FORM, ...Object.fromEntries(Object.entries(supplier).map(([k, v]) => [k, v ?? ''])) })
|
||||
setEditingId(supplier.id)
|
||||
setEditingName(supplier.name)
|
||||
setNotice('')
|
||||
setMode('form')
|
||||
}
|
||||
|
||||
const save = async () => {
|
||||
const payload = {}
|
||||
for (const [key] of FIELDS) payload[key] = form[key]?.trim() || null
|
||||
payload.main_categories = form.main_categories?.trim() || null
|
||||
payload.notes = form.notes?.trim() || null
|
||||
|
||||
if (!payload.name) {
|
||||
setNotice('供应商名称不能为空')
|
||||
return
|
||||
}
|
||||
|
||||
setSaving(true)
|
||||
setNotice('')
|
||||
try {
|
||||
if (editingId) {
|
||||
await axios.put(`${API}/suppliers/${editingId}`, payload)
|
||||
await loadList()
|
||||
await openDetail(editingId)
|
||||
} else {
|
||||
const { data } = await axios.post(`${API}/suppliers`, payload)
|
||||
await loadList()
|
||||
await openDetail(data.id)
|
||||
}
|
||||
} catch (err) {
|
||||
setNotice(err?.response?.data?.error || `保存失败:${err.message}`)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const removeSupplier = async (id, name) => {
|
||||
const ok = window.confirm(
|
||||
`删除供应商「${name}」?\n\n该供应商名下的产品和价格会保留,只是变成「未指定供应商」。`
|
||||
)
|
||||
if (!ok) return
|
||||
try {
|
||||
await axios.delete(`${API}/suppliers/${id}`)
|
||||
await loadList()
|
||||
setDetail(null)
|
||||
setMode('list')
|
||||
} catch (err) {
|
||||
setNotice(err?.response?.data?.error || `删除失败:${err.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------- 列表视图 ------------------------------- */
|
||||
|
||||
if (mode === 'list') {
|
||||
return (
|
||||
<div className="page-container">
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<h1 className="page-title">供应商管理</h1>
|
||||
<p className="page-sub">档案、联系方式、工厂所在地与主营类目</p>
|
||||
</div>
|
||||
<button className="btn btn-primary" onClick={startCreate}>+ 新建供应商</button>
|
||||
</div>
|
||||
|
||||
{notice && <div className="notice-inline">{notice}</div>}
|
||||
|
||||
<div className="toolbar">
|
||||
<input
|
||||
type="text"
|
||||
className="toolbar-search"
|
||||
placeholder="按名称 / 联系人 / 电话 / 地址搜索"
|
||||
value={keyword}
|
||||
onChange={(e) => setKeyword(e.target.value)}
|
||||
/>
|
||||
<div className="toolbar-actions">
|
||||
<span className="secondary">共 {filtered.length} 家</span>
|
||||
<div className="view-toggle" role="group" aria-label="切换排列方式">
|
||||
<button
|
||||
type="button"
|
||||
className={viewMode === 'card' ? 'active' : ''}
|
||||
onClick={() => changeView('card')}
|
||||
title="卡片排列"
|
||||
aria-pressed={viewMode === 'card'}
|
||||
>
|
||||
<GridIcon size={16} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={viewMode === 'table' ? 'active' : ''}
|
||||
onClick={() => changeView('table')}
|
||||
title="列表排列"
|
||||
aria-pressed={viewMode === 'table'}
|
||||
>
|
||||
<ListIcon size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="loading">加载中…</div>
|
||||
) : suppliers.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<div className="empty-state-icon">🏭</div>
|
||||
<div className="empty-state-title">还没有供应商档案</div>
|
||||
<div className="empty-state-hint">
|
||||
建好供应商后,导入合同时就能把价格归到对应供应商名下,查价时也能按供应商筛选。
|
||||
</div>
|
||||
</div>
|
||||
) : viewMode === 'table' ? (
|
||||
<div className="table-container">
|
||||
<table className="data-table supplier-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>供应商</th>
|
||||
<th>联系人</th>
|
||||
<th>电话</th>
|
||||
<th>工厂所在地</th>
|
||||
<th>主营类目</th>
|
||||
<th style={{ textAlign: 'right', width: 72 }}>产品</th>
|
||||
<th style={{ textAlign: 'right', width: 72 }}>货号</th>
|
||||
<th>付款条件</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{unassigned > 0 && (
|
||||
<tr className="row-unassigned" onClick={() => navigate('/products')}>
|
||||
<td className="bold">未分类</td>
|
||||
<td className="secondary" colSpan={4}>还没归属供应商的产品</td>
|
||||
<td className="number" style={{ textAlign: 'right' }}>{unassigned}</td>
|
||||
<td className="number" style={{ textAlign: 'right' }}>—</td>
|
||||
<td className="secondary">—</td>
|
||||
</tr>
|
||||
)}
|
||||
{filtered.map((s) => (
|
||||
<tr key={s.id} onClick={() => openDetail(s.id)}>
|
||||
<td className="bold">{s.name}</td>
|
||||
<td className="secondary">{s.contact_person || '—'}</td>
|
||||
<td className="secondary mono">{s.phone || '—'}</td>
|
||||
<td className="secondary" title={s.address || ''}>
|
||||
{shortLocation(s.address) || '—'}
|
||||
</td>
|
||||
<td>
|
||||
{splitCats(s.main_categories).length
|
||||
? <div className="chip-row">{splitCats(s.main_categories).map((c) => <span className="chip" key={c}>{c}</span>)}</div>
|
||||
: <span className="muted-note">未填</span>}
|
||||
</td>
|
||||
<td className="number" style={{ textAlign: 'right' }}>{s.group_count ?? s.sku_count ?? 0}</td>
|
||||
<td className="number" style={{ textAlign: 'right' }}>{s.sku_count ?? 0}</td>
|
||||
<td className="secondary">{s.payment_terms || '—'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<div className="supplier-grid">
|
||||
{unassigned > 0 && (
|
||||
<div className="supplier-card is-unassigned" onClick={() => navigate('/products')}>
|
||||
<div className="sc-name">未分类</div>
|
||||
<div className="sc-contact"><span className="muted-note">还没归属供应商的产品</span></div>
|
||||
<div className="sc-foot">
|
||||
<span className="sc-count"><b>{unassigned}</b> 个产品</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{filtered.map((s) => {
|
||||
const contact = [s.contact_person, s.phone].filter(Boolean).join(' · ')
|
||||
const loc = shortLocation(s.address)
|
||||
return (
|
||||
<div className="supplier-card" key={s.id} onClick={() => openDetail(s.id)}>
|
||||
<div className="sc-name">{s.name}</div>
|
||||
<div className="sc-contact">
|
||||
{contact || <span className="muted-note">未填联系人</span>}
|
||||
</div>
|
||||
<div className="sc-loc" title={s.address || ''}>
|
||||
{loc || <span className="muted-note">未填工厂所在地</span>}
|
||||
</div>
|
||||
<CatChips text={s.main_categories} />
|
||||
<div className="sc-foot">
|
||||
<span className="sc-count">
|
||||
<b>{s.group_count ?? s.sku_count}</b> 个产品 · {s.sku_count} 个货号
|
||||
</span>
|
||||
{s.payment_terms && <span className="chip">{s.payment_terms}</span>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ------------------------------- 详情视图 ------------------------------- */
|
||||
|
||||
if (mode === 'detail') {
|
||||
const s = detail?.supplier
|
||||
const products = detail?.products || []
|
||||
const contracts = detail?.contracts || []
|
||||
|
||||
return (
|
||||
<div className="page-container">
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<h1 className="page-title">{s?.name || '供应商详情'}</h1>
|
||||
{s && (
|
||||
<p className="page-sub">
|
||||
{s.short_name ? `${s.short_name} · ` : ''}
|
||||
供货 {products.length} 个货号 · {contracts.length} 份合同记录
|
||||
</p>
|
||||
)}
|
||||
{s && <CatChips text={s.main_categories} />}
|
||||
</div>
|
||||
<div className="header-actions">
|
||||
<button className="btn" onClick={() => { setDetail(null); setMode('list') }}>← 供应商列表</button>
|
||||
{s && <button className="btn btn-primary" onClick={() => startEdit(s)}>编辑档案</button>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{notice && <div className="notice-inline">{notice}</div>}
|
||||
|
||||
{detailLoading || !s ? (
|
||||
<div className="loading">加载中…</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="card">
|
||||
<div className="card-header"><h2>联系方式</h2></div>
|
||||
<div className="kv-grid">
|
||||
<Kv label="联系人" value={s.contact_person} />
|
||||
<Kv label="电话" value={s.phone} mono />
|
||||
<Kv label="邮箱" value={s.email} mono />
|
||||
<Kv label="付款条件" value={s.payment_terms} />
|
||||
<Kv label="网址" value={s.website} mono />
|
||||
<Kv label="地址" value={s.address} />
|
||||
</div>
|
||||
{s.notes && (
|
||||
<div className="kv-grid" style={{ marginTop: 14 }}>
|
||||
<Kv label="备注" value={s.notes} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<h2>供货清单</h2>
|
||||
<span className="secondary">{products.length} 条</span>
|
||||
</div>
|
||||
{products.length === 0 ? (
|
||||
<div className="empty-state" style={{ padding: '28px 12px' }}>
|
||||
<div className="empty-state-hint">
|
||||
还没有归到这家供应商的货号。导入合同时在「合同导入」页选上这家供应商即可。
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="table-container" style={{ maxHeight: 460 }}>
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: 56 }}></th>
|
||||
<th>货号</th>
|
||||
<th>所属产品</th>
|
||||
<th style={{ textAlign: 'right' }}>采购价</th>
|
||||
<th style={{ textAlign: 'right' }}>起订量</th>
|
||||
<th>更新时间</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{products.map((p) => (
|
||||
<tr key={`${p.sku}-${p.group_id ?? ''}`}>
|
||||
<td>
|
||||
{p.thumb
|
||||
? <img className="thumb" src={`/product-images/${p.thumb}`} alt="" loading="lazy" />
|
||||
: <span className="thumb thumb-empty" />}
|
||||
</td>
|
||||
<td className="mono">{p.display_sku || p.sku}</td>
|
||||
<td>{p.group_name || p.name || '—'}</td>
|
||||
<td className="number" style={{ textAlign: 'right' }}>
|
||||
<span className="secondary">¥</span>{money(p.price)}
|
||||
{p.confidence === 'low' && <span className="pill pill-warn" style={{ marginLeft: 6 }}>待核对</span>}
|
||||
</td>
|
||||
<td className="number" style={{ textAlign: 'right' }}>{p.moq ?? '—'}</td>
|
||||
<td className="secondary">{fmtDate(p.last_updated, false)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<h2>合同导入记录</h2>
|
||||
<span className="secondary">{contracts.length} 条</span>
|
||||
</div>
|
||||
{contracts.length === 0 ? (
|
||||
<div className="empty-state" style={{ padding: '28px 12px' }}>
|
||||
<div className="empty-state-hint">还没有该供应商的合同导入记录。</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="table-container" style={{ maxHeight: 340 }}>
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>文件名</th>
|
||||
<th>类型</th>
|
||||
<th style={{ textAlign: 'right' }}>新增</th>
|
||||
<th style={{ textAlign: 'right' }}>更新</th>
|
||||
<th>状态</th>
|
||||
<th>时间</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{contracts.map((c) => (
|
||||
<tr key={c.id}>
|
||||
<td className="mono">
|
||||
{c.has_file
|
||||
? <a href={`${API}/contracts/${c.id}/file`} title="下载原件">{c.filename}</a>
|
||||
: c.filename}
|
||||
</td>
|
||||
<td className="secondary">{FILE_TYPE_LABEL[c.file_type] || c.file_type}</td>
|
||||
<td className="number" style={{ textAlign: 'right' }}>{c.rows_new}</td>
|
||||
<td className="number" style={{ textAlign: 'right' }}>{c.rows_matched}</td>
|
||||
<td><span className={STATUS_PILL[c.status] || 'pill pill-mute'}>{STATUS_LABEL[c.status] || c.status}</span></td>
|
||||
<td className="secondary">{fmtDate(c.processed_at)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ------------------------------- 表单视图 ------------------------------- */
|
||||
|
||||
return (
|
||||
<div className="page-container">
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<h1 className="page-title">{editingId ? '编辑供应商' : '新建供应商'}</h1>
|
||||
<p className="page-sub">
|
||||
{editingId ? editingName : '填写基础档案,之后可以随时修改'}
|
||||
</p>
|
||||
</div>
|
||||
<button className="btn" onClick={() => (editingId ? openDetail(editingId) : setMode('list'))}>
|
||||
返回
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{notice && <div className="notice-inline">{notice}</div>}
|
||||
|
||||
<div className="card" style={{ maxWidth: 860 }}>
|
||||
<div className="card-header"><h2>基础信息</h2></div>
|
||||
|
||||
<div className="form-row">
|
||||
{FIELDS.map(([key, label, required]) => (
|
||||
<div className="form-group" key={key}>
|
||||
<label>{label}{required ? ' *' : ''}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form[key] ?? ''}
|
||||
onChange={(e) => setForm((f) => ({ ...f, [key]: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>主营产品类目</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="用顿号或逗号分隔,例如:长嘴钳、老虎钳、斜口钳、卡簧钳"
|
||||
value={form.main_categories}
|
||||
onChange={(e) => setForm((f) => ({ ...f, main_categories: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>备注</label>
|
||||
<textarea
|
||||
rows={3}
|
||||
value={form.notes}
|
||||
onChange={(e) => setForm((f) => ({ ...f, notes: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-actions">
|
||||
<button className="btn btn-primary" onClick={save} disabled={saving}>
|
||||
{saving ? '保存中…' : '保存'}
|
||||
</button>
|
||||
{editingId && (
|
||||
<button
|
||||
className="btn btn-danger"
|
||||
onClick={() => removeSupplier(editingId, editingName)}
|
||||
disabled={saving}
|
||||
>
|
||||
删除该供应商
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default SupplierManagement
|
||||
@@ -0,0 +1,7 @@
|
||||
// Whitelist stored preferences and tolerate blocked browser storage.
|
||||
export function readPreference(key, allowed, fallback) {
|
||||
try {
|
||||
const value = localStorage.getItem(key)
|
||||
return allowed.includes(value) ? value : fallback
|
||||
} catch { return fallback }
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
/* ============================================================
|
||||
应用布局与页面组件
|
||||
============================================================ */
|
||||
|
||||
.app-container { display: flex; min-height: 100vh; background: var(--bg-primary); }
|
||||
|
||||
/* 侧边栏:仪表板式工业控制台 */
|
||||
.sidebar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
width: var(--sidebar-width);
|
||||
height: 100vh;
|
||||
flex: 0 0 var(--sidebar-width);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: linear-gradient(180deg, #0d2539 0%, #091a2a 100%);
|
||||
border-right: 1px solid var(--border);
|
||||
box-shadow: 10px 0 28px rgba(0, 0, 0, 0.2);
|
||||
transition: width var(--transition-base), flex-basis var(--transition-base);
|
||||
z-index: 10;
|
||||
}
|
||||
.sidebar::before { content: ""; height: 3px; flex: 0 0 3px; background: linear-gradient(90deg, var(--primary-dark), var(--primary-light)); }
|
||||
.sidebar.collapsed { width: var(--sidebar-collapsed-width); flex-basis: var(--sidebar-collapsed-width); }
|
||||
.sidebar-header { display: flex; align-items: center; justify-content: space-between; gap: 12px; min-height: 76px; padding: 18px 18px 16px; border-bottom: 1px solid var(--border-light); }
|
||||
.sidebar-header h1 { margin: 0; overflow: hidden; color: var(--text-primary); font-family: var(--font-display); font-size: 16px; font-weight: 700; letter-spacing: 0.02em; white-space: nowrap; }
|
||||
.collapse-btn { flex: 0 0 30px; width: 30px; height: 30px; border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--surface-deep); color: var(--primary-light); cursor: pointer; font-size: 16px; transition: all var(--transition-fast); }
|
||||
.collapse-btn:hover { background: var(--primary); color: #041522; border-color: var(--primary); }
|
||||
.sidebar-nav { flex: 1; overflow-y: auto; padding: 14px 10px; }
|
||||
/* 分组标题行:标题 + 右侧折叠按钮 */
|
||||
.nav-group { display: block; }
|
||||
.nav-group:first-of-type .nav-group-header { margin-top: 14px; padding-top: 12px; border-top: 1px solid var(--border-light); }
|
||||
.nav-group-header { display: flex; align-items: center; justify-content: space-between; gap: 6px; margin: 16px 4px 6px; padding: 0 6px; }
|
||||
.nav-group-title { flex: 1; margin: 0; color: var(--text-tertiary); font-size: 12px; font-weight: 600; letter-spacing: 0.04em; }
|
||||
.nav-group-toggle { display: flex; flex: 0 0 auto; align-items: center; justify-content: center; width: 24px; height: 24px; padding: 0; border: 1px solid transparent; border-radius: var(--radius-sm); background: transparent; color: var(--text-tertiary); cursor: pointer; transition: color var(--transition-fast), background var(--transition-fast), border-color var(--transition-fast); }
|
||||
.nav-group-toggle:hover { color: var(--primary-light); background: var(--surface-hover); border-color: var(--border-light); }
|
||||
.nav-group-toggle svg { transition: transform var(--transition-base); }
|
||||
.nav-group-toggle:not(.open) svg { transform: rotate(-90deg); }
|
||||
.sidebar.collapsed .nav-group-header { display: none; }
|
||||
.nav-item { position: relative; display: flex; align-items: center; gap: 12px; min-height: 42px; margin: 3px 0; padding: 10px 12px; border: 1px solid transparent; border-radius: var(--radius-md); color: var(--text-secondary); text-decoration: none; font-size: 14px; font-weight: 600; transition: all var(--transition-fast); }
|
||||
.nav-item:hover { background: var(--surface-hover); border-color: var(--border-light); color: var(--text-primary); }
|
||||
.nav-item.active { background: linear-gradient(90deg, rgba(42, 167, 220, 0.22), rgba(42, 167, 220, 0.06)); border-color: var(--primary-border); color: var(--text-primary); box-shadow: var(--shadow-inset); }
|
||||
.nav-item.active::before { content: ""; position: absolute; left: -1px; top: 8px; bottom: 8px; width: 3px; border-radius: 0 2px 2px 0; background: var(--primary-light); }
|
||||
.nav-item .icon { display: flex; align-items: center; justify-content: center; min-width: 20px; color: var(--primary-light); }
|
||||
.sidebar.collapsed .nav-item { justify-content: center; padding: 10px; }
|
||||
.sidebar.collapsed .nav-item span:not(.icon) { display: none; }
|
||||
.sidebar-footer { display: flex; align-items: center; gap: 10px; padding: 15px 16px; border-top: 1px solid var(--border-light); }
|
||||
.sidebar-footer .user-info { flex: 1; overflow: hidden; color: var(--text-secondary); font-size: 12px; font-weight: 600; white-space: nowrap; }
|
||||
.settings-btn { display: flex; align-items: center; justify-content: center; width: 32px; height: 32px; border: 1px solid var(--border); border-radius: var(--radius-sm); background: transparent; color: var(--text-tertiary); cursor: pointer; transition: all var(--transition-fast); }
|
||||
.settings-btn:hover { color: var(--primary-light); border-color: var(--primary-border); background: var(--primary-bg); }
|
||||
.sidebar.collapsed .sidebar-footer { justify-content: center; padding: 15px 10px; }
|
||||
.sidebar.collapsed .user-info { display: none; }
|
||||
|
||||
/* 主内容 */
|
||||
.main-content { min-width: 0; flex: 1; overflow-y: auto; padding: 34px clamp(20px, 4vw, 52px) 52px; background-color: var(--bg-primary); background-image: linear-gradient(rgba(126, 190, 221, 0.035) 1px, transparent 1px), linear-gradient(90deg, rgba(126, 190, 221, 0.035) 1px, transparent 1px); background-size: 28px 28px; }
|
||||
.page-container, .dashboard-container, .prospect-container { width: min(1440px, 100%); margin: 0 auto; }
|
||||
.page-header { display: flex; align-items: flex-end; justify-content: space-between; gap: 16px; margin-bottom: 26px; }
|
||||
.page-title { margin: 0 0 24px; color: var(--text-primary); font-family: var(--font-display); font-size: clamp(24px, 3vw, 34px); font-weight: 700; letter-spacing: -0.02em; }
|
||||
.page-header .page-title { margin-bottom: 0; }
|
||||
.page-title::after { content: ""; display: block; width: 42px; height: 3px; margin-top: 10px; background: var(--primary); }
|
||||
|
||||
/* 卡片与统计 */
|
||||
.card, .dashboard-card, .panel { margin-bottom: 20px; padding: 20px; border: 1px solid var(--border); border-radius: var(--radius-lg); background: var(--surface); box-shadow: var(--shadow-raise); }
|
||||
.card-header { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-bottom: 17px; padding-bottom: 13px; border-bottom: 1px solid var(--border-light); }
|
||||
.card-header h2, .panel-title { display: flex; align-items: center; gap: 9px; margin: 0; color: var(--text-primary); font-family: var(--font-display); font-size: 15px; font-weight: 700; }
|
||||
.card-header h2 svg { color: var(--primary-light); }
|
||||
.card-content { color: var(--text-secondary); }
|
||||
.panel-title { margin-bottom: 14px; }
|
||||
.panel-title::after { content: ""; flex: 1; height: 1px; background: var(--border-light); }
|
||||
.stats-grid { display: grid; grid-template-columns: repeat(6, minmax(150px, 1fr)); gap: 14px; margin-bottom: 24px; }
|
||||
.stat-card { display: flex; align-items: center; min-height: 106px; gap: 14px; padding: 18px; border: 1px solid var(--border); border-radius: var(--radius-lg); background: linear-gradient(145deg, var(--surface-raised), var(--surface)); box-shadow: var(--shadow-raise); transition: transform var(--transition-fast), border-color var(--transition-fast); }
|
||||
.stat-card:hover { transform: translateY(-2px); border-color: var(--primary-border); }
|
||||
.stat-icon { display: flex; align-items: center; justify-content: center; flex: 0 0 38px; color: var(--primary-light); }
|
||||
.stat-label { margin-bottom: 5px; color: var(--text-tertiary); font-size: 12px; font-weight: 600; }
|
||||
.stat-value { color: var(--text-primary); font-family: var(--font-mono); font-size: 27px; font-weight: 700; line-height: 1; }
|
||||
.dashboard-grid { display: grid; grid-template-columns: minmax(0, 1.25fr) minmax(360px, 0.75fr); gap: 20px; margin-bottom: 20px; }
|
||||
|
||||
/* 工具栏、表单、搜索 */
|
||||
.toolbar { display: flex; align-items: center; gap: 10px; margin-bottom: 16px; flex-wrap: wrap; }
|
||||
.toolbar-search { flex: 1; min-width: 230px; max-width: 450px; }
|
||||
.toolbar-actions { display: flex; align-items: center; gap: 8px; margin-left: auto; flex-wrap: wrap; }
|
||||
.form-row { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; }
|
||||
.form-group { margin-bottom: 16px; }
|
||||
.form-group label { display: block; margin-bottom: 7px; color: var(--text-secondary); font-size: 12px; font-weight: 700; }
|
||||
.search-input { width: 100%; }
|
||||
.mono { font-family: var(--font-mono); }
|
||||
.number, .price { font-family: var(--font-mono); font-variant-numeric: tabular-nums; }
|
||||
.bold { font-weight: 700; color: var(--text-primary); }
|
||||
.secondary { color: var(--text-tertiary); }
|
||||
|
||||
/* 空状态、待办、活动、日程 */
|
||||
.empty-state { padding: 48px 20px; color: var(--text-tertiary); text-align: center; }
|
||||
.empty-state-icon { margin-bottom: 14px; color: var(--text-disabled); font-size: 40px; }
|
||||
.empty-state-title { margin-bottom: 6px; color: var(--text-secondary); font-weight: 700; }
|
||||
.empty-state-hint { color: var(--text-tertiary); font-size: 13px; }
|
||||
.todo-list, .activity-list, .holiday-list { margin: 0; padding: 0; list-style: none; }
|
||||
.todo-item, .activity-item { display: flex; align-items: flex-start; gap: 11px; padding: 12px 0; border-bottom: 1px solid var(--border-light); }
|
||||
.todo-item:last-child, .activity-item:last-child { border-bottom: 0; }
|
||||
.todo-item input[type="checkbox"] { flex: 0 0 auto; width: 16px; height: 16px; margin-top: 3px; accent-color: var(--primary); }
|
||||
.todo-content { min-width: 0; flex: 1; }
|
||||
.todo-title { color: var(--text-primary); font-size: 13px; font-weight: 600; }
|
||||
.todo-meta, .activity-time { margin-top: 3px; color: var(--text-tertiary); font-size: 11px; }
|
||||
.activity-content { color: var(--text-secondary); font-size: 13px; }
|
||||
.calendar-widget { padding: 3px 0; }
|
||||
.current-date { margin-bottom: 14px; padding: 12px; border: 1px solid var(--border-light); border-radius: var(--radius-md); background: var(--surface-deep); color: var(--primary-light); font-family: var(--font-mono); font-size: 13px; font-weight: 700; text-align: center; }
|
||||
.holiday-item { display: flex; align-items: center; gap: 10px; padding: 9px 0; border-bottom: 1px solid var(--border-light); }
|
||||
.holiday-item:last-child { border-bottom: 0; }
|
||||
.holiday-flag { display: flex; align-items: center; color: var(--primary-light); }
|
||||
.holiday-name { flex: 1; color: var(--text-secondary); font-size: 13px; }
|
||||
.holiday-date { color: var(--text-tertiary); font-family: var(--font-mono); font-size: 12px; }
|
||||
.info-box { margin-top: 18px; padding: 15px 17px; border: 1px solid var(--info-border); border-left: 3px solid var(--info); border-radius: var(--radius-md); background: var(--info-bg); color: var(--text-secondary); }
|
||||
.info-box h3 { margin: 0 0 9px; color: var(--info); font-size: 14px; }
|
||||
.info-box ul { margin: 8px 0 0 18px; padding: 0; }
|
||||
.info-box li { margin-bottom: 5px; }
|
||||
|
||||
/* 设置页 */
|
||||
.settings-modal { display: flex; width: min(1000px, calc(100vw - 32px)); height: min(720px, calc(100vh - 32px)); overflow: hidden; border-radius: var(--radius-lg); }
|
||||
.settings-sidebar { display: flex; flex-direction: column; width: 230px; flex: 0 0 230px; background: var(--surface-deep); border-right: 1px solid var(--border); }
|
||||
.settings-header h2 { margin: 0; font-family: var(--font-display); font-size: 17px; }
|
||||
.settings-nav { flex: 1; padding: 12px; overflow-y: auto; }
|
||||
.settings-nav-item { width: 100%; margin-bottom: 4px; padding: 10px 12px; border: 1px solid transparent; border-radius: var(--radius-md); background: transparent; color: var(--text-secondary); cursor: pointer; text-align: left; font-size: 13px; font-weight: 600; transition: all var(--transition-fast); }
|
||||
.settings-nav-item:hover { background: var(--surface-hover); color: var(--text-primary); }
|
||||
.settings-nav-item.active { background: var(--primary-bg); border-color: var(--primary-border); color: var(--primary-light); }
|
||||
.settings-content { flex: 1; overflow-y: auto; padding: 26px; }
|
||||
.settings-section h3 { margin: 0 0 7px; color: var(--text-primary); font-family: var(--font-display); font-size: 22px; }
|
||||
.settings-description { margin: 0 0 26px; color: var(--text-tertiary); font-size: 13px; }
|
||||
.settings-group { margin-bottom: 26px; padding-bottom: 23px; border-bottom: 1px solid var(--border-light); }
|
||||
.settings-group:last-child { border-bottom: 0; }
|
||||
.settings-group h4 { margin: 0 0 13px; color: var(--text-secondary); font-size: 13px; }
|
||||
.settings-actions { display: flex; flex-wrap: wrap; gap: 9px; }
|
||||
.file-label { cursor: pointer; }
|
||||
.help-text { margin: 9px 0 0; color: var(--text-tertiary); font-size: 12px; }
|
||||
.checkbox-group label { display: flex; align-items: center; gap: 9px; cursor: pointer; }
|
||||
.checkbox-group input[type="checkbox"] { width: 16px; height: 16px; accent-color: var(--primary); }
|
||||
.checkbox-group span { color: var(--text-secondary); font-size: 13px; }
|
||||
|
||||
.loading { display: flex; align-items: center; justify-content: center; min-height: 220px; color: var(--text-tertiary); font-size: 13px; }
|
||||
.spinner { width: 20px; height: 20px; border: 2px solid var(--border); border-top-color: var(--primary); border-radius: 50%; animation: spin 700ms linear infinite; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
@media (max-width: 1180px) { .stats-grid { grid-template-columns: repeat(3, minmax(160px, 1fr)); } }
|
||||
@media (max-width: 900px) { .sidebar { width: var(--sidebar-collapsed-width); flex-basis: var(--sidebar-collapsed-width); } .sidebar-header { justify-content: center; padding: 18px 10px; } .sidebar-header h1 { display: none; } .sidebar-nav { padding-inline: 9px; } .nav-group-header, .nav-item span:not(.icon), .user-info { display: none; } .nav-item { justify-content: center; padding-inline: 10px; } .sidebar-footer { justify-content: center; padding-inline: 10px; } .dashboard-grid { grid-template-columns: 1fr; } }
|
||||
@media (max-width: 620px) { .main-content { padding: 24px 14px 36px; } .stats-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; } .stat-card { min-height: 88px; padding: 13px; } .stat-icon { flex-basis: 30px; } .stat-value { font-size: 22px; } .dashboard-grid { display: block; } .form-row { grid-template-columns: 1fr; } .settings-modal { height: calc(100vh - 20px); width: calc(100vw - 20px); flex-direction: column; } .settings-sidebar { width: auto; flex-basis: auto; } .settings-header { padding: 14px 18px; } .settings-nav { display: flex; gap: 8px; overflow-x: auto; } .settings-nav-item { width: auto; white-space: nowrap; margin: 0; } .settings-content { padding: 20px 18px; } }
|
||||
|
||||
/* 产品页使用的原生 table 同样复用工业表格视觉 */
|
||||
.table-wrap table { width: 100%; border-collapse: separate; border-spacing: 0; font-size: 13px; }
|
||||
.table-wrap thead th { position: sticky; top: 0; z-index: 1; padding: 11px 14px; background: var(--surface-raised); color: var(--text-secondary); border-bottom: 1px solid var(--border-strong); text-align: left; font-size: 11px; letter-spacing: 0.08em; text-transform: uppercase; }
|
||||
.table-wrap tbody td { padding: 12px 14px; color: var(--text-secondary); border-bottom: 1px solid var(--border-light); vertical-align: middle; }
|
||||
.table-wrap tbody tr { cursor: pointer; transition: background var(--transition-fast); }
|
||||
.table-wrap tbody tr:hover td { background: var(--primary-bg); color: var(--text-primary); }
|
||||
.table-wrap tbody tr:last-child td { border-bottom: 0; }
|
||||
|
||||
/* ============================================================
|
||||
供应商管理(由 SKU 迁移为原生 React 页面)
|
||||
============================================================ */
|
||||
|
||||
.page-sub { margin: 6px 0 0; color: var(--text-secondary); font-size: 14px; }
|
||||
.header-actions { display: flex; align-items: center; gap: 8px; }
|
||||
.notice-inline { margin-bottom: 16px; padding: 10px 14px; border: 1px solid var(--danger-border); border-left: 3px solid var(--danger); border-radius: var(--radius-md); background: var(--danger-bg); color: var(--danger); font-size: 13px; }
|
||||
|
||||
.supplier-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(258px, 1fr)); gap: 14px; }
|
||||
.supplier-card { display: flex; flex-direction: column; gap: 7px; padding: 16px 18px; border: 1px solid var(--border); border-radius: var(--radius-lg); background: var(--surface); cursor: pointer; transition: transform var(--transition-fast), border-color var(--transition-fast), background var(--transition-fast); }
|
||||
.supplier-card:hover { transform: translateY(-2px); border-color: var(--primary-border); background: var(--surface-raised); }
|
||||
.supplier-card.is-unassigned { border-style: dashed; }
|
||||
.sc-name { color: var(--text-primary); font-size: 15px; font-weight: 600; }
|
||||
.sc-contact, .sc-loc { overflow: hidden; color: var(--text-secondary); font-size: 13px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.sc-foot { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-top: auto; padding-top: 9px; border-top: 1px solid var(--border-light); color: var(--text-tertiary); font-size: 12px; }
|
||||
.sc-count b { color: var(--text-primary); font-size: 13px; }
|
||||
.muted-note { color: var(--text-tertiary); font-size: 12px; }
|
||||
|
||||
.chip-row { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||
.chip { padding: 2px 9px; border: 1px solid var(--border-light); border-radius: var(--radius-pill); background: var(--surface-deep); color: var(--text-secondary); font-size: 12px; white-space: nowrap; }
|
||||
|
||||
.kv-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(230px, 1fr)); gap: 12px 24px; }
|
||||
.kv { display: flex; flex-direction: column; gap: 3px; padding-bottom: 9px; border-bottom: 1px solid var(--border-light); }
|
||||
.kv-label { color: var(--text-tertiary); font-size: 12px; }
|
||||
.kv-value { color: var(--text-primary); font-size: 14px; word-break: break-word; }
|
||||
.kv-value.is-empty { color: var(--text-disabled); }
|
||||
|
||||
.pill { display: inline-flex; align-items: center; padding: 2px 9px; border-radius: var(--radius-pill); font-size: 12px; font-weight: 600; }
|
||||
.pill-ok { border: 1px solid var(--success-border); background: var(--success-bg); color: var(--success); }
|
||||
.pill-warn { border: 1px solid var(--warning-border); background: var(--warning-bg); color: var(--warning); }
|
||||
.pill-mute { border: 1px solid var(--border-light); background: var(--surface-deep); color: var(--text-tertiary); }
|
||||
|
||||
.thumb { display: block; width: 38px; height: 38px; object-fit: cover; border: 1px solid var(--border-light); border-radius: var(--radius-sm); }
|
||||
.thumb-empty { background: var(--surface-deep); }
|
||||
|
||||
.form-actions { display: flex; align-items: center; gap: 9px; margin-top: 6px; }
|
||||
.form-actions .btn-danger { margin-left: auto; }
|
||||
|
||||
/* ============================================================
|
||||
产品管理(由 SKU 迁移为原生 React 页面)
|
||||
============================================================ */
|
||||
|
||||
/* 表头改成中文友好:原来 11px + 0.08em 字距是给全大写英文设计的,中文会被拉散 */
|
||||
.data-table thead th { font-size: 12px; letter-spacing: 0.02em; text-transform: none; }
|
||||
|
||||
/* 排列方式分段控件 */
|
||||
.view-toggle { display: inline-flex; padding: 2px; border: 1px solid var(--border); border-radius: var(--radius-md); background: var(--surface-deep); }
|
||||
.view-toggle button { display: inline-flex; align-items: center; justify-content: center; width: 30px; height: 26px; border: 0; border-radius: calc(var(--radius-md) - 2px); background: transparent; color: var(--text-tertiary); cursor: pointer; transition: all var(--transition-fast); }
|
||||
.view-toggle button:hover { background: var(--surface-hover); color: var(--text-primary); }
|
||||
.view-toggle button.active { background: var(--primary-bg); color: var(--primary-light); }
|
||||
|
||||
/* 可点击表格行 */
|
||||
.supplier-table tbody tr, .product-table tbody tr { cursor: pointer; }
|
||||
.supplier-table tbody tr.row-unassigned { border-left: 3px dashed var(--border-strong); }
|
||||
.product-table tbody tr.row-picked td { background: var(--primary-bg); }
|
||||
.product-table tbody tr.row-hit td { background: var(--warning-bg); }
|
||||
|
||||
.check-inline { display: inline-flex; align-items: center; gap: 7px; color: var(--text-secondary); font-size: 13px; white-space: nowrap; cursor: pointer; }
|
||||
.check-inline input { width: 15px; height: 15px; accent-color: var(--primary); }
|
||||
|
||||
/* 批量操作条 */
|
||||
.batch-bar { display: flex; align-items: center; gap: 10px; margin-bottom: 14px; padding: 10px 14px; border: 1px solid var(--primary-border); border-radius: var(--radius-md); background: var(--primary-bg); color: var(--text-secondary); font-size: 13px; }
|
||||
.batch-bar b { color: var(--text-primary); }
|
||||
|
||||
.hit-line { margin-top: 3px; color: var(--text-tertiary); font-size: 12px; }
|
||||
|
||||
.thumb-wrap { position: relative; display: inline-block; }
|
||||
.thumb-badge { position: absolute; right: -5px; bottom: -5px; min-width: 16px; padding: 0 4px; border-radius: var(--radius-pill); background: var(--primary); color: #04141d; font-size: 10px; font-weight: 700; line-height: 16px; text-align: center; }
|
||||
.thumb-empty { display: inline-flex; align-items: center; justify-content: center; color: var(--text-disabled); border: 1px dashed var(--border); border-radius: var(--radius-sm); background: var(--surface-deep); }
|
||||
.unit { margin-right: 1px; color: var(--text-tertiary); font-size: 0.85em; }
|
||||
|
||||
.icon-btn { display: inline-flex; align-items: center; justify-content: center; width: 28px; height: 28px; border: 1px solid transparent; border-radius: var(--radius-sm); background: transparent; color: var(--text-tertiary); cursor: pointer; transition: all var(--transition-fast); }
|
||||
.icon-btn:hover { border-color: var(--border); background: var(--surface-hover); color: var(--text-primary); }
|
||||
|
||||
/* 抽屉补充 */
|
||||
.drawer-wide { width: min(940px, 96vw); }
|
||||
.drawer-head-main { min-width: 0; }
|
||||
.drawer-sub { margin: 5px 0 0; color: var(--text-tertiary); font-size: 13px; }
|
||||
|
||||
/* 参数编辑器(参数名 / 值 / 删除) */
|
||||
.attr-editor { display: flex; flex-direction: column; gap: 8px; }
|
||||
.attr-rows { display: flex; flex-direction: column; gap: 7px; }
|
||||
.attr-row { display: grid; grid-template-columns: 1fr 1fr 30px; align-items: center; gap: 8px; }
|
||||
.attr-row input { width: 100%; }
|
||||
.attr-editor > .btn { align-self: flex-start; }
|
||||
|
||||
/* 图片画廊 */
|
||||
.gallery { display: grid; grid-template-columns: repeat(auto-fill, minmax(124px, 1fr)); gap: 12px; }
|
||||
.gallery-item { position: relative; overflow: hidden; border: 1px solid var(--border); border-radius: var(--radius-md); background: var(--surface-deep); }
|
||||
.gallery-item img { display: block; width: 100%; height: 108px; object-fit: cover; cursor: zoom-in; }
|
||||
.gallery-flag { position: absolute; top: 6px; left: 6px; padding: 1px 7px; border-radius: var(--radius-pill); background: var(--primary); color: #04141d; font-size: 11px; font-weight: 700; }
|
||||
.gallery-actions { display: flex; gap: 6px; padding: 7px; border-top: 1px solid var(--border-light); }
|
||||
.gallery-actions .btn { flex: 1; }
|
||||
.add-image { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 6px; min-height: 142px; border: 1px dashed var(--border-strong); border-radius: var(--radius-md); color: var(--text-tertiary); font-size: 12px; cursor: pointer; transition: all var(--transition-fast); }
|
||||
.add-image:hover { border-color: var(--primary-border); background: var(--primary-bg); color: var(--primary-light); }
|
||||
.add-image input { display: none; }
|
||||
|
||||
.lightbox { position: fixed; inset: 0; z-index: 60; display: flex; align-items: center; justify-content: center; padding: 40px; background: rgba(2, 11, 18, 0.86); cursor: zoom-out; }
|
||||
.lightbox img { max-width: 100%; max-height: 100%; border-radius: var(--radius-lg); box-shadow: var(--shadow-md); }
|
||||
|
||||
/* 说明块 */
|
||||
.notice-inline.notice-ok { border-color: var(--success-border); border-left-color: var(--success); background: var(--success-bg); color: var(--success); }
|
||||
.note { display: flex; gap: 9px; margin-bottom: 14px; padding: 11px 14px; border-radius: var(--radius-md); border-left: 3px solid var(--border-strong); background: var(--surface-deep); color: var(--text-secondary); font-size: 13px; line-height: 1.6; }
|
||||
.note-info { border-left-color: var(--primary); }
|
||||
.note-warn { border-left-color: var(--warning); background: var(--warning-bg); }
|
||||
.spec-text { white-space: pre-wrap; color: var(--text-secondary); font-size: 13px; line-height: 1.75; }
|
||||
.terms summary { color: var(--text-secondary); font-size: 13px; cursor: pointer; }
|
||||
.terms .spec-text { margin-top: 9px; }
|
||||
|
||||
/* 调价时间线 */
|
||||
.timeline { display: flex; flex-direction: column; gap: 14px; }
|
||||
.timeline-item { display: flex; gap: 12px; }
|
||||
.timeline-dot { flex: 0 0 8px; width: 8px; height: 8px; margin-top: 6px; border-radius: 50%; background: var(--primary); }
|
||||
.timeline-title { color: var(--text-primary); font-size: 13px; }
|
||||
.timeline-meta { margin-top: 2px; color: var(--text-tertiary); font-size: 12px; }
|
||||
|
||||
/* 小推车 */
|
||||
.cart-badge { display: inline-flex; align-items: center; justify-content: center; min-width: 18px; height: 18px; margin-left: 6px; padding: 0 5px; border-radius: var(--radius-pill); background: var(--primary); color: #04141d; font-size: 11px; font-weight: 700; }
|
||||
.cart-list { display: flex; flex-direction: column; gap: 10px; }
|
||||
.cart-row { display: flex; align-items: center; gap: 12px; padding: 10px 12px; border: 1px solid var(--border); border-radius: var(--radius-md); background: var(--surface); }
|
||||
.cart-thumb { flex: 0 0 auto; }
|
||||
.cart-main { flex: 1; min-width: 0; }
|
||||
.cart-name { overflow: hidden; color: var(--text-primary); font-size: 14px; font-weight: 600; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.cart-meta { overflow: hidden; margin-top: 2px; color: var(--text-tertiary); font-size: 12px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.cart-meta code { font-family: var(--font-mono); }
|
||||
.cart-price { flex: 0 0 auto; min-width: 96px; text-align: right; color: var(--text-primary); font-size: 14px; }
|
||||
.cart-qty { display: flex; flex-direction: column; gap: 3px; flex: 0 0 auto; }
|
||||
.cart-qty span { color: var(--text-tertiary); font-size: 11px; }
|
||||
.cart-qty input { width: 84px; }
|
||||
.cart-total { align-self: center; color: var(--text-secondary); font-size: 13px; }
|
||||
.cart-total b { color: var(--text-primary); font-size: 16px; }
|
||||
|
||||
/* 窄屏:批量条与抽屉内表单换行 */
|
||||
@media (max-width: 720px) {
|
||||
.batch-bar { flex-wrap: wrap; }
|
||||
.attr-row { grid-template-columns: 1fr 1fr 28px; }
|
||||
.cart-row { flex-wrap: wrap; }
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
/* ============================================================
|
||||
工业蓝色设计系统
|
||||
高对比度、小圆角、清晰边界;阴影仅用于层级与交互反馈。
|
||||
============================================================ */
|
||||
|
||||
:root {
|
||||
--bg-primary: #081522;
|
||||
--bg-secondary: #0d2132;
|
||||
--bg-elevated: #102a40;
|
||||
--surface: #102a40;
|
||||
--surface-raised: #143650;
|
||||
--surface-hover: #193f5d;
|
||||
--surface-deep: #0b2031;
|
||||
--surface-alt: #0d263a;
|
||||
|
||||
--border-light: rgba(163, 211, 235, 0.16);
|
||||
--border: rgba(163, 211, 235, 0.28);
|
||||
--border-strong: rgba(163, 211, 235, 0.52);
|
||||
--line: rgba(126, 190, 221, 0.2);
|
||||
|
||||
--text-primary: #f3f9fc;
|
||||
--text-secondary: #c1d5df;
|
||||
--text-tertiary: #89a7b7;
|
||||
--text-disabled: #607d8c;
|
||||
|
||||
--primary: #2aa7dc;
|
||||
--primary-dark: #1684b7;
|
||||
--primary-light: #7ed7f5;
|
||||
--primary-bg: rgba(42, 167, 220, 0.15);
|
||||
--primary-border: rgba(126, 215, 245, 0.42);
|
||||
|
||||
--success: #53d69b;
|
||||
--success-bg: rgba(83, 214, 155, 0.14);
|
||||
--success-border: rgba(83, 214, 155, 0.42);
|
||||
--warning: #ffc857;
|
||||
--warning-bg: rgba(255, 200, 87, 0.14);
|
||||
--warning-border: rgba(255, 200, 87, 0.42);
|
||||
--danger: #ff7777;
|
||||
--danger-bg: rgba(255, 119, 119, 0.14);
|
||||
--danger-border: rgba(255, 119, 119, 0.42);
|
||||
--info: #63b8ff;
|
||||
--info-bg: rgba(99, 184, 255, 0.14);
|
||||
--info-border: rgba(99, 184, 255, 0.42);
|
||||
|
||||
--radius-sm: 3px;
|
||||
--radius-md: 5px;
|
||||
--radius-lg: 8px;
|
||||
--radius-xl: 10px;
|
||||
--radius-pill: 999px;
|
||||
|
||||
--shadow-sm: 0 2px 8px rgba(0, 0, 0, 0.22);
|
||||
--shadow-md: 0 10px 26px rgba(0, 0, 0, 0.28);
|
||||
--shadow-lg: 0 22px 52px rgba(0, 0, 0, 0.42);
|
||||
--shadow-raise: -4px -4px 12px rgba(101, 175, 211, 0.06), 6px 8px 18px rgba(0, 0, 0, 0.28);
|
||||
--shadow-inset: inset 2px 2px 7px rgba(0, 0, 0, 0.24), inset -2px -2px 7px rgba(93, 171, 208, 0.04);
|
||||
|
||||
/* 字体策略:
|
||||
1) 英文/数字字形统一 Arial(保证中英混排时拉丁部分字形一致、笔画清晰);
|
||||
2) Arial 不含 CJK 字形,中文自动回退到后面的中文字体;
|
||||
3) 夜间(深底)优先微软雅黑——笔画更饱满,深底不显发虚;
|
||||
日间(浅底)优先苹方/思源黑体——笔画更利落,浅底不糊。 */
|
||||
--font-body: Arial, "Microsoft YaHei", "PingFang SC", "Hiragino Sans GB", "Source Han Sans SC", "Noto Sans SC", "Segoe UI", system-ui, sans-serif;
|
||||
--font-display: Arial, "Microsoft YaHei", "PingFang SC", "Source Han Sans SC", "Noto Sans SC", sans-serif;
|
||||
--font-mono: "Cascadia Mono", "Consolas", "SFMono-Regular", "Courier New", monospace;
|
||||
--transition-fast: 140ms cubic-bezier(0.2, 0.8, 0.2, 1);
|
||||
--transition-base: 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
|
||||
|
||||
/* legacy aliases retained for existing screens */
|
||||
--primary-color: var(--primary);
|
||||
--primary-hover: var(--primary-light);
|
||||
--secondary-color: var(--text-secondary);
|
||||
--success-color: var(--success);
|
||||
--warning-color: var(--warning);
|
||||
--danger-color: var(--danger);
|
||||
--bg-color: var(--bg-primary);
|
||||
--card-bg: var(--surface);
|
||||
--border-color: var(--border);
|
||||
--text-primary-legacy: var(--text-primary);
|
||||
--text-secondary-legacy: var(--text-secondary);
|
||||
--sidebar-width: 264px;
|
||||
--sidebar-collapsed-width: 76px;
|
||||
}
|
||||
|
||||
[data-theme="light"] {
|
||||
--bg-primary: #eaf2f6;
|
||||
--bg-secondary: #dceaf1;
|
||||
--bg-elevated: #f6fafc;
|
||||
--surface: #f6fafc;
|
||||
--surface-raised: #ffffff;
|
||||
--surface-hover: #e6f2f8;
|
||||
--surface-deep: #d9e8f0;
|
||||
--surface-alt: #edf5f8;
|
||||
--border-light: rgba(13, 44, 64, 0.12);
|
||||
--border: rgba(13, 44, 64, 0.24);
|
||||
--border-strong: rgba(13, 44, 64, 0.42);
|
||||
--line: rgba(13, 44, 64, 0.16);
|
||||
--text-primary: #102536;
|
||||
--text-secondary: #345267;
|
||||
--text-tertiary: #607b8c;
|
||||
--text-disabled: #93a9b5;
|
||||
--shadow-sm: 0 2px 8px rgba(19, 54, 75, 0.13);
|
||||
--shadow-md: 0 10px 26px rgba(19, 54, 75, 0.16);
|
||||
--shadow-lg: 0 22px 52px rgba(19, 54, 75, 0.2);
|
||||
|
||||
/* 日间配色字体栈:浅底优先字形利落的苹方 / 思源黑体,英文仍为 Arial */
|
||||
--font-body: Arial, "PingFang SC", "Hiragino Sans GB", "Source Han Sans SC", "Noto Sans SC", "Microsoft YaHei", system-ui, sans-serif;
|
||||
--font-display: Arial, "PingFang SC", "Source Han Sans SC", "Noto Sans SC", "Microsoft YaHei", sans-serif;
|
||||
}
|
||||
|
||||
/* 浅色底用次像素渲染更锐利;深色底由 body 默认的 antialiased 处理 */
|
||||
[data-theme="light"] body { -webkit-font-smoothing: auto; }
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
html, body, #root { min-height: 100%; }
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: var(--font-body);
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
line-height: 1.55;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
button, input, select, textarea { font: inherit; }
|
||||
button { color: inherit; }
|
||||
button:focus-visible, a:focus-visible, input:focus-visible, select:focus-visible, textarea:focus-visible {
|
||||
outline: 2px solid var(--primary-light);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
a { color: inherit; }
|
||||
|
||||
/* 通用控件 */
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
min-height: 36px;
|
||||
padding: 8px 14px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--surface);
|
||||
color: var(--text-primary);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: transform var(--transition-fast), background var(--transition-fast), border-color var(--transition-fast), box-shadow var(--transition-fast);
|
||||
}
|
||||
.btn:hover { background: var(--surface-hover); border-color: var(--border-strong); box-shadow: var(--shadow-sm); transform: translateY(-1px); }
|
||||
.btn:active { transform: translateY(0); box-shadow: var(--shadow-inset); }
|
||||
.btn-primary { background: var(--primary); border-color: var(--primary); color: #031521; box-shadow: 0 4px 12px rgba(42, 167, 220, 0.22); }
|
||||
.btn-primary:hover { background: var(--primary-light); border-color: var(--primary-light); color: #062033; }
|
||||
.btn-secondary { background: var(--surface-raised); }
|
||||
.btn-danger { color: var(--danger); }
|
||||
.btn-danger:hover { background: var(--danger-bg); border-color: var(--danger-border); }
|
||||
.btn-sm { min-height: 30px; padding: 5px 10px; font-size: 12px; }
|
||||
.btn-icon { min-width: 34px; padding: 7px; }
|
||||
.btn-link { border: 0; background: transparent; color: var(--primary-light); font-weight: 600; cursor: pointer; padding: 5px 0; }
|
||||
.btn-link:hover { color: var(--text-primary); }
|
||||
|
||||
input[type="text"], input[type="search"], input[type="number"], input[type="email"], input[type="tel"], input[type="url"], input[type="password"], select, textarea, .form-input, .form-select {
|
||||
width: 100%;
|
||||
min-height: 38px;
|
||||
padding: 9px 11px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--surface-deep);
|
||||
color: var(--text-primary);
|
||||
transition: border-color var(--transition-fast), box-shadow var(--transition-fast), background var(--transition-fast);
|
||||
}
|
||||
input::placeholder, textarea::placeholder { color: var(--text-tertiary); }
|
||||
input:focus, select:focus, textarea:focus, .form-input:focus, .form-select:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary);
|
||||
background: var(--surface);
|
||||
box-shadow: 0 0 0 3px rgba(42, 167, 220, 0.14);
|
||||
}
|
||||
|
||||
/* 标签、状态、表格 */
|
||||
.badge, .status-badge, .priority-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 24px;
|
||||
padding: 2px 8px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.badge-primary, .status-active, .status-confirmed { background: var(--primary-bg); color: var(--primary-light); border-color: var(--primary-border); }
|
||||
.badge-success, .status-completed { background: var(--success-bg); color: var(--success); border-color: var(--success-border); }
|
||||
.badge-warning, .status-production, .priority-medium { background: var(--warning-bg); color: var(--warning); border-color: var(--warning-border); }
|
||||
.badge-danger, .status-shipped, .priority-high { background: var(--danger-bg); color: var(--danger); border-color: var(--danger-border); }
|
||||
.badge-neutral, .status-draft, .priority-low { background: var(--surface-alt); color: var(--text-secondary); }
|
||||
|
||||
.data-table { width: 100%; border-collapse: separate; border-spacing: 0; font-size: 13px; }
|
||||
.data-table thead th { position: sticky; top: 0; z-index: 1; padding: 11px 14px; background: var(--surface-raised); color: var(--text-secondary); border-bottom: 1px solid var(--border-strong); text-align: left; font-size: 11px; letter-spacing: 0.08em; text-transform: uppercase; }
|
||||
.data-table tbody td { padding: 12px 14px; color: var(--text-secondary); border-bottom: 1px solid var(--border-light); vertical-align: middle; }
|
||||
.data-table tbody tr:hover td { background: var(--primary-bg); color: var(--text-primary); }
|
||||
.data-table tbody tr:last-child td { border-bottom: 0; }
|
||||
.table-container, .table-wrap { overflow: auto; border: 1px solid var(--border); border-radius: var(--radius-md); background: var(--surface-deep); }
|
||||
|
||||
/* 抽屉、弹窗、提示 */
|
||||
.modal-overlay, .settings-overlay, .drawer-backdrop { position: fixed; inset: 0; z-index: 40; display: flex; align-items: center; justify-content: center; background: rgba(2, 11, 18, 0.78); backdrop-filter: blur(5px); }
|
||||
.modal-content, .settings-modal, .drawer { background: var(--surface); border: 1px solid var(--border-strong); box-shadow: var(--shadow-lg); }
|
||||
.modal-content { width: min(720px, calc(100vw - 32px)); max-height: calc(100vh - 32px); overflow: auto; border-radius: var(--radius-lg); }
|
||||
.modal-header, .drawer-header, .settings-header { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 18px 22px; border-bottom: 1px solid var(--border); }
|
||||
.modal-header h2, .drawer-title { margin: 0; font-family: var(--font-display); font-size: 18px; }
|
||||
.modal-close, .close-btn { border: 0; background: transparent; color: var(--text-tertiary); cursor: pointer; font-size: 24px; line-height: 1; padding: 2px 6px; }
|
||||
.modal-close:hover, .close-btn:hover { color: var(--text-primary); background: var(--surface-hover); }
|
||||
.modal-content form, .modal-footer { padding: 18px 22px; }
|
||||
.modal-footer, .drawer-footer { display: flex; justify-content: flex-end; gap: 10px; border-top: 1px solid var(--border); }
|
||||
.drawer-backdrop { display: block; }
|
||||
.drawer { position: fixed; top: 0; right: 0; bottom: 0; z-index: 41; width: min(620px, 92vw); display: flex; flex-direction: column; border-radius: var(--radius-lg) 0 0 var(--radius-lg); animation: slideIn 240ms var(--transition-base); }
|
||||
.drawer-body { flex: 1; overflow: auto; padding: 22px; }
|
||||
.drawer-footer { padding: 14px 22px; }
|
||||
@keyframes slideIn { from { transform: translateX(100%); } to { transform: translateX(0); } }
|
||||
.toast { position: fixed; right: 22px; bottom: 22px; z-index: 50; max-width: 400px; padding: 13px 16px; border: 1px solid var(--border); border-left: 3px solid var(--primary); border-radius: var(--radius-md); background: var(--surface); box-shadow: var(--shadow-lg); }
|
||||
.toast-success { border-left-color: var(--success); } .toast-warning { border-left-color: var(--warning); } .toast-error { border-left-color: var(--danger); }
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after { animation-duration: 0.01ms !important; transition-duration: 0.01ms !important; }
|
||||
}
|
||||
@@ -0,0 +1,354 @@
|
||||
/* Dark Luxury / steel-blue edition. Loaded after the legacy layout.
|
||||
Keep data dense and legible; use the reference's soft panels and subtle light,
|
||||
not landing-page marquees, animated counters, or decorative fake status data. */
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
--bg-primary: #0c1018;
|
||||
--bg-secondary: #101620;
|
||||
--surface: #161d29;
|
||||
--surface-raised: #1b2534;
|
||||
--surface-deep: #101721;
|
||||
--surface-alt: #141d2a;
|
||||
--surface-hover: #2b3e59;
|
||||
--text-primary: #edf2fc;
|
||||
--text-secondary: #bbc8dc;
|
||||
--text-tertiary: #96a7bf;
|
||||
--primary: #84b5ff;
|
||||
--primary-dark: #6297ea;
|
||||
--primary-light: #aecfff;
|
||||
--primary-bg: #26466b;
|
||||
--primary-border: #638ac0;
|
||||
--border-light: #293447;
|
||||
--border: #45546b;
|
||||
--border-strong: #71829b;
|
||||
--radius-sm: 6px;
|
||||
--radius-md: 10px;
|
||||
--radius-lg: 16px;
|
||||
--radius-xl: 24px;
|
||||
--accent-glow: rgba(103, 157, 237, .14);
|
||||
--panel-shadow: inset 0 1px 0 rgba(218, 230, 255, .09), 0 12px 30px rgba(0, 0, 0, .18);
|
||||
--panel-highlight: rgba(218, 230, 255, .09);
|
||||
--ambient: rgba(77, 115, 174, .12);
|
||||
--grain-opacity: .025;
|
||||
}
|
||||
[data-theme="light"] {
|
||||
color-scheme: light;
|
||||
--bg-primary: #f0f4fa;
|
||||
--bg-secondary: #e9eff7;
|
||||
--surface: #ffffff;
|
||||
--surface-raised: #f4f7fc;
|
||||
--surface-deep: #f1f5fa;
|
||||
--surface-alt: #f7f9fd;
|
||||
--surface-hover: #d3e2f6;
|
||||
--text-primary: #17283f;
|
||||
--text-secondary: #40546f;
|
||||
--text-tertiary: #546780;
|
||||
--text-disabled: #72819a;
|
||||
--primary: #265fbd;
|
||||
--primary-dark: #1d4d9d;
|
||||
--primary-light: #265fbd;
|
||||
--primary-bg: #cfe2fb;
|
||||
--primary-border: #799cd0;
|
||||
--border-light: #dbe3ef;
|
||||
--border: #97a7bd;
|
||||
--border-strong: #7085a0;
|
||||
--success: #12613e;
|
||||
--success-bg: #e1f4e9;
|
||||
--success-border: #70a989;
|
||||
--warning: #805000;
|
||||
--warning-bg: #fff1d4;
|
||||
--warning-border: #b89754;
|
||||
--danger: #ab3038;
|
||||
--danger-bg: #ffeaed;
|
||||
--danger-border: #c57c86;
|
||||
--info: #265fbd;
|
||||
--info-bg: #e8f0ff;
|
||||
--info-border: #799cd0;
|
||||
--accent-glow: rgba(57, 105, 181, .1);
|
||||
--panel-shadow: inset 0 1px 0 #fff, 0 8px 28px rgba(41, 69, 111, .07);
|
||||
--panel-highlight: #fff;
|
||||
--ambient: rgba(144, 178, 230, .13);
|
||||
--grain-opacity: .016;
|
||||
}
|
||||
/* Fine grain is deliberately subtle and never intercepts clicks. */
|
||||
body::before {
|
||||
content: ""; position: fixed; inset: 0; pointer-events: none; z-index: 9999;
|
||||
opacity: var(--grain-opacity); mix-blend-mode: soft-light;
|
||||
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.9' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
|
||||
background-size: 128px;
|
||||
}
|
||||
body { font-size: 14px; }
|
||||
.sidebar { background: var(--bg-secondary); box-shadow: none; border-color: var(--border-light); }
|
||||
.sidebar::before { background: transparent; }
|
||||
.sidebar-header { min-height: 92px; }
|
||||
.sidebar.collapsed .sidebar-header h1 { display: none; }
|
||||
.sidebar.collapsed .sidebar-header { justify-content: center; }
|
||||
.nav-item { min-height: 46px; font-weight: 600; }
|
||||
.nav-item.active { border-color: transparent; background: var(--primary-bg); box-shadow: inset 0 1px 0 var(--panel-highlight); }
|
||||
.nav-item.active::before { display: none; }
|
||||
.nav-item .icon { color: var(--text-tertiary); }
|
||||
.nav-item.active .icon { color: var(--primary-light); }
|
||||
.collapse-btn { border: 0; background: transparent; border-radius: 50%; color: var(--text-secondary); }
|
||||
.main-content { background: radial-gradient(ellipse at 50% 100%, var(--ambient), transparent 65%), var(--bg-primary); background-size: auto; }
|
||||
.page-title { font-size: clamp(24px, 2.5vw, 32px); font-weight: 700; }
|
||||
.page-title::after { display: none; }
|
||||
.card, .dashboard-card, .panel, .stat-card { background: var(--surface); border: 0; border-radius: var(--radius-lg); box-shadow: var(--panel-shadow); }
|
||||
.stat-card { gap: 14px; }
|
||||
.stat-label { color: var(--text-secondary); }
|
||||
.stat-icon { color: var(--primary-light); }
|
||||
.card-header { border-color: var(--border-light); }
|
||||
.table-container, .table-wrap { border-color: var(--border-light); background: var(--surface); border-radius: 14px; box-shadow: var(--panel-shadow); }
|
||||
.table-wrap table, .data-table { min-width: 680px; }
|
||||
.data-table td.empty-state { padding: 48px 20px; text-align: center; }
|
||||
.settings-btn { width: 40px; height: 40px; border-radius: 50%; border-color: var(--border-light); background: var(--surface); color: var(--text-secondary); box-shadow: inset 0 1px 0 var(--panel-highlight); }
|
||||
.settings-btn svg { width: 20px; height: 20px; }
|
||||
.settings-btn:hover { background: var(--primary-bg); color: var(--primary-light); border-color: var(--primary-border); }
|
||||
/* Include standalone legacy buttons as well as .btn components. */
|
||||
.btn, .btn-primary, .btn-secondary {
|
||||
display: inline-flex; align-items: center; justify-content: center; gap: 8px;
|
||||
min-height: 38px; padding: 9px 15px; border: 1px solid var(--border);
|
||||
border-radius: 10px; font: 600 13px var(--font-body); cursor: pointer;
|
||||
transition: background 150ms ease, box-shadow 150ms ease, transform 150ms ease;
|
||||
}
|
||||
.btn-secondary { color: var(--text-primary); background: var(--surface); box-shadow: none; }
|
||||
.btn-primary { background: var(--surface-deep); border-color: var(--primary-border); color: var(--primary-light); box-shadow: 0 0 2px var(--accent-glow), 0 3px 16px var(--accent-glow), inset 0 1px 0 var(--panel-highlight); }
|
||||
.btn-primary:hover { background: var(--primary-bg); color: var(--text-primary); border-color: var(--primary); box-shadow: 0 0 4px var(--accent-glow), 0 3px 24px var(--accent-glow); }
|
||||
.btn-secondary:hover { background: var(--surface-hover); }
|
||||
.btn-sm { min-height: 32px; padding: 5px 10px; }
|
||||
.modal-content, .drawer { border-color: var(--border-light); border-radius: 22px; }
|
||||
.drawer { border-radius: 22px 0 0 22px; }
|
||||
input:focus-visible, select:focus-visible, textarea:focus-visible { outline: 2px solid var(--primary); outline-offset: 2px; }
|
||||
/* Settings shell: a single rounded, softly illuminated surface. */
|
||||
.settings-overlay { padding: 24px; background: rgba(4, 9, 18, .64); backdrop-filter: blur(9px); }
|
||||
.settings-modal.luxury-settings {
|
||||
position: relative; width: min(1020px, 100%); height: min(700px, calc(100dvh - 48px));
|
||||
display: flex; flex-direction: row; overflow: hidden; border: 0;
|
||||
border-radius: 26px; background: var(--surface);
|
||||
box-shadow: inset 0 1px 0 var(--panel-highlight), 0 35px 100px rgba(0, 0, 0, .3);
|
||||
animation: settings-enter 220ms ease-out;
|
||||
}
|
||||
@keyframes settings-enter { from { opacity: 0; transform: translateY(8px) scale(.99); } to { opacity: 1; transform: none; } }
|
||||
.luxury-settings .settings-sidebar { width: 224px; flex: 0 0 224px; padding-bottom: 24px; background: var(--bg-secondary); border-color: var(--border-light); }
|
||||
.luxury-settings .settings-header { padding: 32px 22px 26px; display: block; border: 0; }
|
||||
.luxury-settings .settings-header h2 { font-size: 22px; letter-spacing: -.03em; }
|
||||
.section-label { display: block; color: var(--text-tertiary); font: 10px var(--font-mono); letter-spacing: .12em; margin-bottom: 12px; }
|
||||
.luxury-settings .settings-header .close-btn { position: absolute; right: 18px; top: 18px; z-index: 2; width: 34px; height: 34px; display: grid; place-items: center; border-radius: 50%; background: var(--surface-deep); color: var(--text-secondary); }
|
||||
.luxury-settings .settings-header .close-btn:hover { background: var(--surface-hover); }
|
||||
.luxury-settings .settings-nav { padding: 8px 14px; }
|
||||
.luxury-settings .settings-nav-item { display: flex; align-items: center; gap: 10px; min-height: 44px; border: 0; border-radius: 11px; padding: 12px; margin-bottom: 6px; font-size: 13px; }
|
||||
.luxury-settings .settings-nav-item.active { color: var(--primary-light); background: var(--primary-bg); box-shadow: inset 0 1px 0 var(--panel-highlight); }
|
||||
.settings-nav-icon { display: flex; align-items: center; flex: 0 0 18px; }
|
||||
.settings-sidebar-status { display: flex; align-items: flex-start; gap: 9px; margin: auto 22px 0; color: var(--text-tertiary); font-size: 11px; }
|
||||
.settings-sidebar-status small { display: block; margin-top: 7px; font: 9px var(--font-mono); letter-spacing: .06em; }
|
||||
.luxury-settings .settings-content { min-width: 0; padding: 42px 38px 30px; background: radial-gradient(ellipse at 50% 100%, var(--ambient), transparent 65%); }
|
||||
.luxury-settings .settings-section h3 { font-size: 28px; font-weight: 700; letter-spacing: -.04em; }
|
||||
.luxury-settings .settings-description { color: var(--text-secondary); font-size: 13px; line-height: 1.8; margin-bottom: 28px; }
|
||||
.luxury-settings .settings-group { margin: 0 0 22px; padding: 20px; border: 0; border-radius: 16px; background: var(--surface-deep); box-shadow: inset 0 1px 0 var(--panel-highlight); }
|
||||
.luxury-settings .settings-group h4 { margin: 0 0 12px; font-size: 13px; color: var(--text-primary); }
|
||||
.theme-selector { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 18px; margin-bottom: 26px; }
|
||||
.theme-option { position: relative; padding: 10px 10px 15px; min-width: 0; text-align: left; color: var(--text-primary); background: var(--surface-deep); border: 1px solid var(--border); border-radius: 17px; cursor: pointer; transition: box-shadow 180ms, border-color 180ms; }
|
||||
.theme-option.active { border-color: var(--primary); box-shadow: 0 0 0 1px var(--primary), 0 5px 22px var(--accent-glow); }
|
||||
.theme-option:hover { border-color: var(--primary); }
|
||||
.theme-option > span:not(.theme-preview):not(.theme-check) { display: block; margin: 13px 8px 0; }
|
||||
.theme-option strong, .theme-option small { display: block; }
|
||||
.theme-option strong { font-size: 13px; }
|
||||
.theme-option small { color: var(--text-tertiary); font-size: 11px; margin-top: 5px; }
|
||||
.theme-check { position: absolute; bottom: 27px; right: 15px; display: grid; place-items: center; width: 20px; height: 20px; border: 1px solid var(--border); border-radius: 50%; }
|
||||
.theme-option.active .theme-check { color: var(--surface-deep); background: var(--primary); border-color: var(--primary); }
|
||||
/* Miniature previews deliberately retain their own colors across both themes. */
|
||||
.theme-preview { display: flex; height: 120px; overflow: hidden; border-radius: 10px; }
|
||||
.theme-preview-dark { --mini-bg: #0c1018; --mini-panel: #1b273a; --mini-line: #3e5677; --mini-accent: #aecfff; background: var(--mini-bg); }
|
||||
.theme-preview-light { --mini-bg: #edf2fa; --mini-panel: #fff; --mini-line: #c5d3e9; --mini-accent: #265fbd; background: var(--mini-bg); }
|
||||
.mini-sidebar { flex: 0 0 22%; padding: 18px 8px; background: var(--mini-panel); }
|
||||
.mini-sidebar i { display: block; height: 4px; margin-bottom: 10px; border-radius: 2px; background: var(--mini-line); }
|
||||
.mini-workspace { flex: 1; padding: 12px; color: var(--mini-accent); }
|
||||
.mini-stats { display: flex; gap: 5px; margin: 8px 0; }
|
||||
.mini-stats i { flex: 1; height: 20px; border-radius: 4px; background: var(--mini-panel); box-shadow: inset 0 1px 0 var(--mini-line); }
|
||||
.mini-table { display: block; padding: 5px 8px; border-radius: 4px; background: var(--mini-panel); }
|
||||
.mini-table i { display: block; height: 2px; margin: 4px 0; background: var(--mini-line); }
|
||||
.setting-toggle, .setting-info { position: relative; display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 13px 0; border-top: 1px solid var(--border-light); }
|
||||
.setting-toggle { cursor: pointer; }
|
||||
.setting-toggle strong, .setting-info strong { display: block; font-size: 13px; color: var(--text-primary); font-weight: 500; }
|
||||
.setting-toggle small, .setting-info small { display: block; color: var(--text-tertiary); font-size: 12px; line-height: 1.7; margin-top: 3px; }
|
||||
.preference-badge { flex-shrink: 0; font-size: 10px; color: var(--text-secondary); background: var(--surface-raised); border-radius: 20px; padding: 4px 8px; }
|
||||
.setting-toggle input { position: absolute; width: 42px; height: 24px; right: 0; opacity: 0; }
|
||||
.setting-toggle i { position: relative; pointer-events: none; width: 42px; height: 24px; flex: 0 0 42px; border: 1px solid var(--border-strong); border-radius: 99px; background: var(--surface-hover); transition: background 150ms; }
|
||||
.setting-toggle i::after { content: ""; position: absolute; width: 16px; height: 16px; left: 3px; top: 3px; border-radius: 50%; background: var(--text-secondary); transition: transform 150ms; }
|
||||
.setting-toggle input:checked + i { background: var(--primary); border-color: var(--primary); }
|
||||
.setting-toggle input:checked + i::after { transform: translateX(18px); background: var(--surface); }
|
||||
.setting-toggle input:focus-visible + i { outline: 2px solid var(--primary); outline-offset: 3px; }
|
||||
.appearance-note { display: flex; align-items: center; gap: 7px; font-size: 11px; color: var(--text-tertiary); margin: 0; }
|
||||
.file-label input[type="file"] { display: none; }
|
||||
@media (max-width: 1100px) {
|
||||
.stats-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
.settings-overlay { padding: 12px; }
|
||||
.settings-modal.luxury-settings { height: calc(100dvh - 24px); flex-direction: column; border-radius: 22px; }
|
||||
.luxury-settings .settings-sidebar { width: auto; flex: 0 0 auto; padding: 0; border: 0; }
|
||||
.luxury-settings .settings-header { padding: 20px 22px 12px; }
|
||||
.luxury-settings .settings-header .section-label { display: none; }
|
||||
.luxury-settings .settings-nav { display: flex; gap: 6px; padding: 0 16px 12px; overflow-x: auto; }
|
||||
.luxury-settings .settings-nav-item { white-space: nowrap; width: auto; flex: 1 0 auto; margin: 0; padding: 10px; }
|
||||
.settings-sidebar-status { display: none; }
|
||||
.luxury-settings .settings-content { padding: 24px 22px; }
|
||||
.theme-selector { gap: 12px; }
|
||||
.theme-preview { height: 96px; }
|
||||
}
|
||||
@media (max-width: 620px) {
|
||||
.page-header { flex-wrap: wrap; align-items: center; }
|
||||
.stats-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.stat-card { gap: 8px; padding: 12px; }
|
||||
.stat-icon { flex-basis: 22px; }
|
||||
.stat-icon svg { width: 22px; height: 22px; }
|
||||
.stat-label { font-size: 11px; }
|
||||
.toolbar-search { min-width: 0; flex-basis: 100%; }
|
||||
.settings-nav-item .settings-nav-icon { display: none; }
|
||||
.luxury-settings .settings-group { padding: 16px; }
|
||||
.theme-option { padding: 8px 8px 12px; }
|
||||
.theme-option > span:not(.theme-preview):not(.theme-check) { margin-left: 0; }
|
||||
.theme-check { right: 10px; bottom: 14px; width: 16px; height: 16px; }
|
||||
.theme-option small { font-size: 10px; }
|
||||
.setting-info { flex-wrap: wrap; gap: 6px; }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after { animation-duration: .01ms !important; transition-duration: .01ms !important; scroll-behavior: auto !important; }
|
||||
}
|
||||
[data-motion="reduced"] *, [data-motion="reduced"] *::before, [data-motion="reduced"] *::after { animation-duration: .01ms !important; transition-duration: .01ms !important; scroll-behavior: auto !important; }
|
||||
|
||||
/* ============================================================
|
||||
Global header + calendar dashboard + AI assistant
|
||||
============================================================ */
|
||||
.app-container { display: flex; min-height: 100vh; }
|
||||
.main-content { min-height: 100vh; overflow: hidden; padding: 0; }
|
||||
.route-content { min-height: calc(100vh - 72px); overflow-y: auto; padding: 32px clamp(18px, 3.6vw, 52px) 56px; }
|
||||
.global-header { position: sticky; top: 0; z-index: 25; display: flex; align-items: center; gap: 22px; min-height: 72px; padding: 0 clamp(18px, 3.6vw, 52px); background: color-mix(in srgb, var(--bg-primary) 90%, transparent); border-bottom: 1px solid var(--border-light); backdrop-filter: blur(18px) saturate(1.25); }
|
||||
.global-header-brand { display: flex; align-items: center; gap: 10px; min-width: 238px; }
|
||||
.global-header-brand strong, .global-header-brand span { display: block; }
|
||||
.global-header-brand strong { font-size: 13px; letter-spacing: .02em; }
|
||||
.global-header-brand span:not(.header-status-dot) { color: var(--text-tertiary); font: 9px var(--font-mono); letter-spacing: .12em; margin-top: 3px; }
|
||||
.header-status-dot { width: 8px; height: 8px; flex: 0 0 8px; border-radius: 50%; background: var(--success); box-shadow: 0 0 10px var(--success); }
|
||||
.global-search { display: flex; align-items: center; gap: 9px; width: min(420px, 35vw); min-height: 38px; padding: 0 11px; color: var(--text-tertiary); background: var(--surface-deep); border: 1px solid var(--border-light); border-radius: 11px; transition: border-color 150ms, box-shadow 150ms; }
|
||||
.global-search:focus-within { border-color: var(--primary-border); box-shadow: 0 0 0 3px var(--accent-glow); }
|
||||
.global-search input { min-height: 0; padding: 0; border: 0; background: transparent; box-shadow: none; color: var(--text-primary); }
|
||||
.global-search input:focus { box-shadow: none; }
|
||||
.global-search kbd { margin-left: auto; padding: 2px 5px; color: var(--text-tertiary); background: var(--surface); border: 1px solid var(--border-light); border-radius: 5px; font: 10px var(--font-mono); }
|
||||
.global-header-actions { display: flex; align-items: center; gap: 9px; margin-left: auto; }
|
||||
.header-clock { display: flex; align-items: center; gap: 8px; margin-right: 8px; color: var(--text-tertiary); font: 11px var(--font-mono); white-space: nowrap; }
|
||||
.header-clock b { color: var(--text-primary); font-size: 13px; font-weight: 500; }
|
||||
.quick-create-btn, .ai-entry-btn, .mobile-search-btn { display: inline-flex; align-items: center; gap: 7px; min-height: 36px; padding: 8px 11px; border: 1px solid transparent; border-radius: 10px; background: transparent; color: var(--text-secondary); cursor: pointer; font-size: 12px; transition: background 150ms, border-color 150ms, color 150ms; }
|
||||
.quick-create-btn:hover, .mobile-search-btn:hover { color: var(--text-primary); background: var(--surface-hover); border-color: var(--border-light); }
|
||||
.ai-entry-btn { color: var(--primary-light); background: var(--primary-bg); border-color: var(--primary-border); box-shadow: 0 0 18px var(--accent-glow); }
|
||||
.ai-entry-btn:hover { color: var(--text-primary); background: var(--surface-hover); }
|
||||
.ai-entry-btn i { padding: 1px 4px; border-radius: 4px; background: var(--primary); color: var(--surface-deep); font: 9px var(--font-mono); font-style: normal; }
|
||||
.header-avatar { width: 34px; height: 34px; border: 1px solid var(--primary-border); border-radius: 50%; background: linear-gradient(135deg, var(--primary), var(--surface-raised)); color: var(--surface-deep); cursor: pointer; font-weight: 800; }
|
||||
.mobile-search-btn { display: none; }
|
||||
|
||||
.dashboard-container { width: min(1480px, 100%); margin: 0 auto; }
|
||||
.dashboard-welcome { display: flex; align-items: flex-end; justify-content: space-between; gap: 18px; margin-bottom: 22px; }
|
||||
.dashboard-welcome .page-title { margin: 0; }
|
||||
.dashboard-welcome p { margin: 7px 0 0; color: var(--text-secondary); font-size: 13px; }
|
||||
.dashboard-date-chip { display: grid; gap: 5px; min-width: 150px; padding: 12px 14px; border-radius: 12px; background: var(--surface); box-shadow: var(--panel-shadow); text-align: right; }
|
||||
.dashboard-date-chip span { color: var(--text-tertiary); font: 10px var(--font-mono); letter-spacing: .08em; }
|
||||
.dashboard-date-chip strong { color: var(--text-primary); font-size: 13px; font-weight: 600; }
|
||||
.dashboard-panel { margin-bottom: 18px; padding: 20px; border-radius: 16px; background: var(--surface); box-shadow: var(--panel-shadow); }
|
||||
.dashboard-panel-header { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-bottom: 16px; }
|
||||
.dashboard-panel-header h2 { display: flex; align-items: center; gap: 8px; margin: 0; color: var(--text-primary); font-size: 15px; }
|
||||
.dashboard-panel-header .section-label { margin-bottom: 7px; }
|
||||
.calendar-board { overflow: hidden; padding: 0; }
|
||||
.calendar-board-header { padding: 20px 22px 16px; margin: 0; border-bottom: 1px solid var(--border-light); }
|
||||
.calendar-toolbar { display: flex; align-items: center; gap: 8px; color: var(--text-secondary); }
|
||||
.calendar-toolbar button { display: inline-grid; place-items: center; min-width: 30px; height: 30px; border: 1px solid transparent; border-radius: 8px; background: transparent; color: var(--text-secondary); cursor: pointer; }
|
||||
.calendar-toolbar button:hover { background: var(--surface-hover); border-color: var(--border-light); color: var(--text-primary); }
|
||||
.calendar-toolbar strong { min-width: 94px; color: var(--text-primary); text-align: center; font-size: 13px; }
|
||||
.calendar-toolbar .calendar-today-btn, .calendar-toolbar .calendar-view-btn { padding: 0 9px; border-color: var(--border-light); background: var(--surface-deep); font-size: 11px; }
|
||||
.calendar-week-row, .calendar-grid { display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)); }
|
||||
.calendar-week-row { background: var(--surface-deep); border-bottom: 1px solid var(--border-light); }
|
||||
.calendar-week-row > div { padding: 10px 12px; color: var(--text-tertiary); font: 10px var(--font-mono); letter-spacing: .08em; }
|
||||
.calendar-day { min-height: 92px; padding: 8px 9px; border-right: 1px solid var(--border-light); border-bottom: 1px solid var(--border-light); background: var(--surface); }
|
||||
.calendar-day:nth-child(7n) { border-right: 0; }
|
||||
.calendar-day.is-outside { background: var(--surface-deep); opacity: .58; }
|
||||
.calendar-day.is-today { background: linear-gradient(180deg, var(--primary-bg), var(--surface)); }
|
||||
.calendar-day-number { display: inline-grid; place-items: center; min-width: 22px; height: 22px; color: var(--text-secondary); font: 11px var(--font-mono); }
|
||||
.calendar-day.is-today .calendar-day-number { border-radius: 50%; background: var(--primary); color: var(--surface-deep); font-weight: 800; }
|
||||
.calendar-events { display: grid; gap: 4px; margin-top: 5px; }
|
||||
.calendar-event { display: block; overflow: hidden; padding: 3px 5px; border-left: 2px solid var(--primary); border-radius: 3px; background: var(--primary-bg); color: var(--text-secondary); font-size: 10px; line-height: 1.35; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.calendar-event.danger { border-color: var(--danger); background: var(--danger-bg); color: var(--danger); }
|
||||
.calendar-event.holiday { border-color: var(--success); background: var(--success-bg); color: var(--success); }
|
||||
.dashboard-content-grid { display: grid; grid-template-columns: minmax(0, 1.52fr) minmax(280px, .72fr); gap: 18px; align-items: start; }
|
||||
.dashboard-primary-column, .dashboard-secondary-column { min-width: 0; }
|
||||
.ai-digest-panel { background: linear-gradient(135deg, color-mix(in srgb, var(--primary-bg) 82%, var(--surface)), var(--surface)); }
|
||||
.ai-digest-body { padding: 16px 18px; border-radius: 12px; background: color-mix(in srgb, var(--surface) 70%, transparent); box-shadow: inset 0 1px 0 var(--panel-highlight); }
|
||||
.ai-digest-body strong { color: var(--text-primary); font-size: 14px; }
|
||||
.ai-digest-body p { margin: 8px 0 12px; color: var(--text-secondary); line-height: 1.75; }
|
||||
.ai-digest-body b { color: var(--primary-light); }
|
||||
.digest-tags { display: flex; flex-wrap: wrap; gap: 7px; }
|
||||
.digest-tags span, .panel-period { padding: 4px 8px; border-radius: 99px; background: var(--surface-raised); color: var(--text-tertiary); font: 10px var(--font-mono); }
|
||||
.spark-mark { color: var(--primary-light); }
|
||||
.panel-live-status { display: inline-flex; align-items: center; gap: 6px; color: var(--success); font: 10px var(--font-mono); }
|
||||
.panel-live-status i, .dot { display: inline-block; width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
|
||||
.panel-link { border: 0; background: transparent; color: var(--primary-light); cursor: pointer; font-size: 12px; }
|
||||
.dashboard-empty { padding: 26px 8px; color: var(--text-tertiary); text-align: center; }
|
||||
.stat-rail .stat-line { display: grid; grid-template-columns: 26px 1fr auto; align-items: center; gap: 8px; padding: 13px 0; border-top: 1px solid var(--border-light); color: var(--text-secondary); font-size: 12px; }
|
||||
.stat-line-icon { display: grid; place-items: center; color: var(--primary-light); }
|
||||
.stat-line strong { color: var(--text-primary); font: 700 19px var(--font-mono); }
|
||||
.completion-panel { text-align: center; }
|
||||
.completion-panel .dashboard-panel-header { text-align: left; }
|
||||
.completion-ring { display: grid; place-items: center; width: 142px; height: 142px; margin: 18px auto; border-radius: 50%; background: conic-gradient(var(--primary) var(--progress), var(--surface-deep) 0); }
|
||||
.completion-ring::before { content: ""; position: absolute; width: 106px; height: 106px; border-radius: 50%; background: var(--surface); }
|
||||
.completion-ring > div { position: relative; display: grid; gap: 3px; }
|
||||
.completion-ring strong { color: var(--text-primary); font: 700 27px var(--font-mono); }
|
||||
.completion-ring span { color: var(--text-tertiary); font-size: 11px; }
|
||||
.completion-legend { display: flex; justify-content: center; gap: 16px; color: var(--text-tertiary); font-size: 11px; }
|
||||
.completion-legend span { display: flex; align-items: center; gap: 6px; }
|
||||
.completion-legend .done { color: var(--primary); }.completion-legend .pending { color: var(--text-disabled); }
|
||||
.quick-panel > button { display: flex; align-items: center; gap: 8px; width: 100%; padding: 11px 0; border: 0; border-top: 1px solid var(--border-light); background: transparent; color: var(--text-secondary); cursor: pointer; text-align: left; font-size: 12px; }.quick-panel > button:hover { color: var(--primary-light); }
|
||||
|
||||
/* AI assistant drawer */
|
||||
.assistant-layer { position: fixed; inset: 0; z-index: 80; background: rgba(3, 8, 15, .46); backdrop-filter: blur(5px); }
|
||||
.ai-assistant-panel { position: absolute; top: 14px; right: 14px; bottom: 14px; display: flex; flex-direction: column; width: min(430px, calc(100vw - 28px)); overflow: hidden; border: 1px solid var(--border-light); border-radius: 22px; background: var(--surface); box-shadow: 0 28px 80px rgba(0,0,0,.36), inset 0 1px 0 var(--panel-highlight); animation: assistant-in 220ms var(--ease-out); }
|
||||
@keyframes assistant-in { from { opacity: 0; transform: translateX(22px); } to { opacity: 1; transform: translateX(0); } }
|
||||
.ai-panel-header { display: flex; align-items: center; justify-content: space-between; padding: 20px 20px 14px; }
|
||||
.ai-panel-title { display: flex; align-items: center; gap: 11px; }.ai-panel-title h2 { margin: 0; font-size: 21px; }.ai-panel-title .section-label { margin-bottom: 4px; }
|
||||
.ai-orb { display: grid; place-items: center; width: 42px; height: 42px; border-radius: 15px; color: var(--primary-light); background: var(--primary-bg); box-shadow: 0 0 20px var(--accent-glow); }
|
||||
.ai-close-btn { display: grid; place-items: center; width: 32px; height: 32px; border: 0; border-radius: 50%; background: var(--surface-deep); color: var(--text-secondary); cursor: pointer; }.ai-close-btn:hover { background: var(--surface-hover); color: var(--text-primary); }
|
||||
.ai-access-banner { display: flex; align-items: center; gap: 10px; margin: 0 20px 14px; padding: 10px 12px; border: 1px solid var(--primary-border); border-radius: 12px; background: var(--primary-bg); }.ai-access-banner strong, .ai-access-banner small { display: block; }.ai-access-banner strong { color: var(--text-primary); font-size: 12px; }.ai-access-banner small { margin-top: 3px; color: var(--text-secondary); font-size: 10px; }.ai-live-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--success); box-shadow: 0 0 8px var(--success); }
|
||||
.ai-panel-body { flex: 1; overflow-y: auto; padding: 0 20px 16px; }.ai-capability-card { padding: 13px; border-radius: 14px; background: var(--surface-deep); box-shadow: inset 0 1px 0 var(--panel-highlight); }.ai-capability-heading { display: flex; align-items: center; gap: 6px; margin-bottom: 10px; color: var(--primary-light); font-size: 12px; font-weight: 700; }.ai-capability-row { display: flex; gap: 7px; padding: 4px 0; color: var(--text-secondary); font-size: 11px; }.ai-capability-row span { color: var(--success); }
|
||||
.ai-context-strip { display: flex; flex-wrap: wrap; gap: 7px; margin: 12px 0; color: var(--text-tertiary); font-size: 10px; }.ai-context-strip span { margin-right: auto; }.ai-context-strip b { padding: 3px 6px; border-radius: 5px; color: var(--text-secondary); background: var(--surface-deep); font-weight: 500; }
|
||||
.ai-message-list { display: grid; gap: 13px; margin-top: 14px; }.ai-message { display: flex; align-items: flex-start; gap: 8px; }.ai-message.user { flex-direction: row-reverse; }.ai-message-avatar { display: grid; place-items: center; width: 25px; height: 25px; flex: 0 0 25px; border-radius: 9px; background: var(--surface-deep); color: var(--primary-light); font-size: 10px; font-weight: 800; }.ai-message.user .ai-message-avatar { color: var(--surface-deep); background: var(--primary); }.ai-message-content { max-width: 82%; padding: 9px 11px; border-radius: 4px 13px 13px 13px; background: var(--surface-deep); }.ai-message.user .ai-message-content { border-radius: 13px 4px 13px 13px; background: var(--primary-bg); }.ai-message-content p { margin: 0; color: var(--text-secondary); font-size: 12px; line-height: 1.7; }.ai-message-content small { display: block; margin-top: 5px; color: var(--text-tertiary); font: 9px var(--font-mono); }.ai-thinking { display: flex; gap: 4px; align-items: center; min-height: 34px; }.ai-thinking span { width: 5px; height: 5px; border-radius: 50%; background: var(--primary); animation: ai-bounce 1s infinite ease-in-out; }.ai-thinking span:nth-child(2) { animation-delay: .15s; }.ai-thinking span:nth-child(3) { animation-delay: .3s; }@keyframes ai-bounce { 0%,80%,100% { transform: translateY(0); opacity:.45; } 40% { transform: translateY(-4px); opacity:1; } }
|
||||
.ai-suggestion-row { display: flex; gap: 7px; padding: 8px 20px; overflow-x: auto; }.ai-suggestion-row button { flex: 0 0 auto; padding: 6px 9px; border: 1px solid var(--border-light); border-radius: 99px; background: var(--surface-deep); color: var(--text-secondary); cursor: pointer; font-size: 10px; }.ai-suggestion-row button:hover { color: var(--primary-light); border-color: var(--primary-border); }
|
||||
.ai-input-row { display: flex; gap: 8px; padding: 10px 16px; border-top: 1px solid var(--border-light); }.ai-input-row input { min-height: 40px; background: var(--surface-deep); }.ai-input-row button { display: grid; place-items: center; width: 40px; min-width: 40px; border: 0; border-radius: 11px; background: var(--primary); color: var(--surface-deep); cursor: pointer; }.ai-input-row button:disabled { cursor: not-allowed; opacity: .4; }.ai-panel-footer { padding: 8px 20px 13px; color: var(--text-tertiary); font-size: 10px; text-align: center; }
|
||||
@media (max-width: 980px) { .global-header-brand { min-width: auto; }.global-header-brand div { display: none; }.global-search { width: min(360px, 40vw); }.header-clock span { display: none; }.dashboard-content-grid { grid-template-columns: 1fr; }.dashboard-secondary-column { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 18px; }.dashboard-secondary-column .dashboard-panel { margin-bottom: 0; } }
|
||||
@media (max-width: 680px) { .global-header { gap: 8px; min-height: 62px; padding: 0 12px; }.global-search { display: none; }.global-search.is-open { position: absolute; left: 12px; right: 12px; top: 65px; display: flex; width: auto; background: var(--surface); }.mobile-search-btn { display: inline-flex; }.quick-create-btn span, .ai-entry-btn span, .header-clock { display: none; }.ai-entry-btn { padding: 8px; }.route-content { min-height: calc(100vh - 62px); padding: 22px 12px 42px; }.dashboard-welcome { align-items: flex-start; flex-direction: column; }.dashboard-date-chip { text-align: left; }.calendar-day { min-height: 68px; padding: 5px 4px; }.calendar-week-row > div { padding: 8px 4px; }.calendar-event { font-size: 8px; }.calendar-day-number { font-size: 10px; }.dashboard-secondary-column { display: block; }.dashboard-secondary-column .dashboard-panel { margin-bottom: 18px; }.ai-assistant-panel { top: 8px; right: 8px; bottom: 8px; width: calc(100vw - 16px); } }
|
||||
|
||||
/* Requested compact navigation controls. */
|
||||
.collapse-btn { display: grid; place-items: center; width: 32px; height: 32px; padding: 0; border: 1px solid var(--border-strong); border-radius: 8px; background: var(--surface-deep); color: var(--text-secondary); box-shadow: inset 0 1px 0 var(--panel-highlight); }
|
||||
.collapse-btn:hover { color: var(--primary-light); border-color: var(--primary-border); background: var(--primary-bg); }
|
||||
.collapse-btn svg { width: 19px; height: 19px; }
|
||||
.sidebar-footer { gap: 8px; }
|
||||
.theme-toggle-btn { display: grid; place-items: center; width: 40px; height: 40px; margin-left: auto; padding: 0; border: 1px solid var(--border-light); border-radius: 50%; background: var(--surface); color: var(--primary-light); box-shadow: inset 0 1px 0 var(--panel-highlight); cursor: pointer; transition: background 150ms, border-color 150ms, transform 150ms; }
|
||||
.theme-toggle-btn:hover { border-color: var(--primary-border); background: var(--primary-bg); transform: translateY(-1px); }
|
||||
.sidebar.collapsed .theme-toggle-btn { margin-left: 0; }
|
||||
|
||||
/* Search everything: results are scoped to ordinary business data only. */
|
||||
.global-search { position: relative; }
|
||||
.global-search-results { position: absolute; top: calc(100% + 9px); left: 0; width: min(580px, calc(100vw - 32px)); max-height: min(520px, calc(100vh - 100px)); overflow-y: auto; padding: 7px; border: 1px solid var(--border); border-radius: 14px; background: var(--surface); box-shadow: 0 20px 52px rgba(0, 0, 0, .25), inset 0 1px 0 var(--panel-highlight); }
|
||||
.global-search-results button { display: grid; grid-template-columns: 58px minmax(0, 1fr); gap: 10px; width: 100%; padding: 10px; border: 0; border-radius: 10px; background: transparent; color: var(--text-primary); cursor: pointer; text-align: left; }
|
||||
.global-search-results button:hover, .global-search-results button:focus-visible { background: var(--surface-hover); outline: none; }
|
||||
.search-result-mark { align-self: start; padding: 3px 5px; border-radius: 5px; background: var(--surface-deep); color: var(--text-tertiary); font-size: 10px; text-align: center; }
|
||||
.search-result-mark.product, .search-result-mark.customer { color: var(--primary-light); background: var(--primary-bg); }
|
||||
.search-result-mark.order, .search-result-mark.deal { color: var(--warning); background: var(--warning-bg); }
|
||||
.search-result-mark.todo, .search-result-mark.activity { color: var(--success); background: var(--success-bg); }
|
||||
.global-search-results strong, .global-search-results small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.global-search-results strong { font-size: 12px; }
|
||||
.global-search-results small { margin-top: 3px; color: var(--text-tertiary); font-size: 10px; }
|
||||
.global-search-status { padding: 17px 12px; color: var(--text-tertiary); font-size: 12px; text-align: center; }
|
||||
.global-search-footnote { margin: 4px 3px 1px; padding: 9px 8px 4px; border-top: 1px solid var(--border-light); color: var(--text-tertiary); font-size: 10px; }
|
||||
|
||||
.product-category-summary { display: flex; flex-wrap: wrap; gap: 7px; margin: -4px 0 15px; }
|
||||
.product-category-summary button { padding: 5px 9px; border: 1px solid var(--border-light); border-radius: 99px; background: var(--surface); color: var(--text-secondary); cursor: pointer; font-size: 11px; }
|
||||
.product-category-summary button:hover, .product-category-summary button.active { border-color: var(--primary-border); background: var(--primary-bg); color: var(--primary-light); }
|
||||
@media (max-width: 680px) { .global-search-results { position: fixed; top: 70px; left: 12px; width: calc(100vw - 24px); }.sidebar-footer { flex-wrap: wrap; }.theme-toggle-btn { margin-left: 0; } }
|
||||
|
||||
/* Preserve the dense industrial table's column hierarchy on smaller desktops. */
|
||||
.table-wrap table { min-width: 980px; }
|
||||
@@ -0,0 +1,26 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 5300,
|
||||
proxy: {
|
||||
// SKU 能力代理(过渡期):前端统一以 /api/sku/* 访问,避免跨域与端口耦合。
|
||||
// 后端正式合并(P1)完成后可直接删除本段。
|
||||
'/api/sku': {
|
||||
target: 'http://127.0.0.1:3300',
|
||||
changeOrigin: true,
|
||||
rewrite: (p) => p.replace(/^\/api\/sku/, '/api')
|
||||
},
|
||||
'/product-images': {
|
||||
target: 'http://127.0.0.1:3300',
|
||||
changeOrigin: true
|
||||
},
|
||||
'/api': {
|
||||
target: 'http://127.0.0.1:4300',
|
||||
changeOrigin: true
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,35 @@
|
||||
@echo off
|
||||
chcp 65001 >nul
|
||||
echo ========================================
|
||||
echo 外贸供应链管理系统
|
||||
echo ========================================
|
||||
echo.
|
||||
|
||||
cd /d "%~dp0"
|
||||
|
||||
echo 正在启动后端服务器...
|
||||
cd backend
|
||||
start "后端服务器" cmd /k "npm start"
|
||||
|
||||
timeout /t 2 >nul
|
||||
|
||||
echo 正在启动前端开发服务器...
|
||||
cd ..\frontend
|
||||
start "前端开发服务器" cmd /k "npm run dev"
|
||||
|
||||
timeout /t 3 >nul
|
||||
|
||||
echo.
|
||||
echo ✓ 服务已启动
|
||||
echo.
|
||||
echo 前端地址: http://127.0.0.1:5300
|
||||
echo 后端地址: http://127.0.0.1:4300
|
||||
echo.
|
||||
echo 按任意键打开浏览器...
|
||||
pause >nul
|
||||
|
||||
start http://127.0.0.1:5300
|
||||
|
||||
echo.
|
||||
echo 提示: 关闭服务请关闭对应的命令行窗口
|
||||
pause
|
||||
Reference in New Issue
Block a user