feat: 完整功能迭代 — 移动端+PC端全面优化

新增:
- 今日纪要 (6分类+彩色标签+时间选择器)
- 客户经理PC端工作台 (我的数据,5模块CRUD)
- 用户管理页 (支局长修改角色)
- 客户备注/收支费用(金额+单位)/联系人管理
- 拜访人姓名+电话字段
- 客户导入导出/旧周报导入模板下载
- 序号列+分页(25/50/100)

修复/优化:
- Hash路由→HTML5 History, Casdoor回调正常
- 时区统一为Asia/Shanghai (today_cst)
- 数据库懒加载→selectinload预加载
- 日期解析兼容ISO datetime字符串
- 文件上传定位修复 (position:relative)
- 表单button type='button'防止误提交
- 分管领导权限(只读客户档案,不可编辑)
- 侧边栏折叠+SVG图标
- 拜访方式/紧急度统一chip风格
- 时间范围统一为el-time-picker(is-range)
- 全局CSS设计变量+box-sizing修复滚动条

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-06-23 11:40:03 +08:00
parent a1886074dd
commit e7af0e15ab
37 changed files with 4561 additions and 643 deletions
+8
View File
@@ -0,0 +1,8 @@
{
"hash": "e75be2da",
"configHash": "ef9a524a",
"lockfileHash": "19c2bb83",
"browserHash": "dc47218b",
"optimized": {},
"chunks": {}
}
+3
View File
@@ -0,0 +1,3 @@
{
"type": "module"
}
+17 -1
View File
@@ -93,9 +93,21 @@ async def list_customers(
offset = (page - 1) * page_size
query = base_query.order_by(Customer.name).offset(offset).limit(page_size)
result = await db.execute(query)
items = result.scalars().all()
# Enrich with manager names
if items:
mgr_result = await db.execute(
select(CustomerAssignment.customer_id, User.name)
.join(User, CustomerAssignment.manager_id == User.id)
.where(CustomerAssignment.customer_id.in_([c.id for c in items]), CustomerAssignment.role == "primary")
)
mgr_map = {str(cid): name for cid, name in mgr_result.all()}
for item in items:
item.primary_manager_name = mgr_map.get(str(item.id), None)
return CustomerListResponse(
items=result.scalars().all(),
items=items,
total=total,
page=page,
page_size=page_size,
@@ -350,6 +362,8 @@ async def create_customer(
current_user: dict = Depends(require_any_role),
db: AsyncSession = Depends(get_db),
):
if current_user["role"] == "leader":
raise HTTPException(status_code=403, detail="分管领导无法创建客户")
"""Create a new customer with optional contacts and manager assignment."""
import uuid
customer = Customer(
@@ -390,6 +404,8 @@ async def update_customer(
current_user: dict = Depends(require_any_role),
db: AsyncSession = Depends(get_db),
):
if current_user["role"] == "leader":
raise HTTPException(status_code=403, detail="分管领导无法编辑客户")
result = await db.execute(
select(Customer).where(Customer.id == customer_id).options(selectinload(Customer.contacts))
)
+51 -6
View File
@@ -1,5 +1,7 @@
import io
import uuid
from fastapi import APIRouter, Depends, File, UploadFile, HTTPException
from fastapi.responses import StreamingResponse
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.middleware.auth import get_current_user, require_director
@@ -8,6 +10,54 @@ from app.services.excel_import import import_from_excel
router = APIRouter(prefix="/import", tags=["Import"])
@router.get("/template")
async def download_weekly_report_template():
"""Download a 4-sheet weekly report import template."""
from openpyxl import Workbook
from openpyxl.styles import Font
wb = Workbook()
header_font = Font(bold=True)
# Sheet 1: 每日拜访记录
ws1 = wb.active
ws1.title = "每日拜访记录"
ws1.append(["客户单位", "拜访日期", "拜访方式", "时间范围", "拜访人姓名", "拜访人电话", "沟通内容", "客户需求", "同访人员", "客户经理"])
for c in ws1[1]: c.font = header_font
ws1.append(["XX科技有限公司", "2026-06-23", "上门", "9:00-10:00", "韦柳柏", "13800000000", "沟通了解云桌面需求", "希望扩容", "", "韦矍森"])
ws1.column_dimensions['A'].width = 20; ws1.column_dimensions['E'].width = 30; ws1.column_dimensions['F'].width = 20
# Sheet 2: 下周工作计划
ws2 = wb.create_sheet("下周工作计划")
ws2.append(["客户单位", "工作计划", "计划拜访时间", "客户经理", "状态"])
for c in ws2[1]: c.font = header_font
ws2.append(["XX科技有限公司", "跟进云桌面扩容方案", "2026-06-30", "韦柳柏", "计划中"])
ws2.column_dimensions['A'].width = 20; ws2.column_dimensions['B'].width = 35
# Sheet 3: 小微业务商机
ws3 = wb.create_sheet("小微业务商机")
ws3.append(["客户单位", "产品类型", "金额", "跟进内容具体情况", "跟进状态", "客户经理", "预计列收时间"])
for c in ws3[1]: c.font = header_font
ws3.append(["XX科技有限公司", "云桌面", "5000元/月", "确认技术方案中", "跟进中", "韦柳柏", "2026Q3"])
ws3.column_dimensions['A'].width = 20; ws3.column_dimensions['D'].width = 30
# Sheet 4: 要客拜访计划
ws4 = wb.create_sheet("要客拜访计划")
ws4.append(["客户单位", "紧急重要度", "内容描述", "进展状态", "计划拜访时间", "计划拜访人", "拜访对象", "客户经理"])
for c in ws4[1]: c.font = header_font
ws4.append(["XX科技有限公司", "重要", "拜访技术负责人确认方案", "未开始", "2026-07-01", "韦柳柏", "王局长", "韦矍森"])
ws4.column_dimensions['A'].width = 20; ws4.column_dimensions['C'].width = 30
output = io.BytesIO()
wb.save(output)
output.seek(0)
return StreamingResponse(
output,
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={"Content-Disposition": "attachment; filename=weekly_report_template.xlsx"},
)
@router.post("/weekly-report")
async def import_weekly_report(
file: UploadFile = File(...),
@@ -20,7 +70,6 @@ async def import_weekly_report(
content = await file.read()
# Preview first: parse headers
try:
import openpyxl
wb = openpyxl.load_workbook(io.BytesIO(content), data_only=True)
@@ -28,16 +77,12 @@ async def import_weekly_report(
for sheet_name in wb.sheetnames:
ws = wb[sheet_name]
headers = [str(cell.value) for cell in ws[1]]
row_count = ws.max_row - 1 # minus header
row_count = ws.max_row - 1
preview[sheet_name] = {"headers": headers, "row_count": row_count}
except Exception as e:
raise HTTPException(status_code=400, detail=f"Failed to parse Excel: {str(e)}")
# Import data
stats = await import_from_excel(db, content, uuid.UUID(current_user["user_id"]))
stats["preview"] = preview
return stats
import io
+2
View File
@@ -34,6 +34,8 @@ async def _enrich_visit(visit: Visit, db: AsyncSession) -> dict:
"visit_date": visit.visit_date,
"visit_method": visit.visit_method,
"time_range": visit.time_range,
"visitor_name": visit.visitor_name or "",
"visitor_phone": visit.visitor_phone or "",
"communication_content": visit.communication_content,
"customer_demand": visit.customer_demand,
"companions": visit.companions,
+6
View File
@@ -17,6 +17,12 @@ async def lifespan(app: FastAPI):
await conn.run_sync(lambda c: c.exec_driver_sql(
"ALTER TABLE customers ADD COLUMN IF NOT EXISTS remarks TEXT DEFAULT ''"
))
await conn.run_sync(lambda c: c.exec_driver_sql(
"ALTER TABLE visits ADD COLUMN IF NOT EXISTS visitor_name VARCHAR(50) DEFAULT ''"
))
await conn.run_sync(lambda c: c.exec_driver_sql(
"ALTER TABLE visits ADD COLUMN IF NOT EXISTS visitor_phone VARCHAR(20) DEFAULT ''"
))
yield
# Shutdown
await engine.dispose()
+2
View File
@@ -16,6 +16,8 @@ class Visit(Base):
time_range: Mapped[str] = mapped_column(String(30), default="")
communication_content: Mapped[str] = mapped_column(Text, default="")
customer_demand: Mapped[str] = mapped_column(Text, default="")
visitor_name: Mapped[str] = mapped_column(String(50), default="")
visitor_phone: Mapped[str] = mapped_column(String(20), default="")
companions: Mapped[list | None] = mapped_column(ARRAY(UUID(as_uuid=True)), nullable=True)
photos: Mapped[list | None] = mapped_column(ARRAY(Text), nullable=True)
manager_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), index=True)
+1
View File
@@ -84,6 +84,7 @@ class CustomerListOut(BaseModel):
name: str
industry: str
in_use_services: str
primary_manager_name: Optional[str] = None
model_config = {"from_attributes": True}
+6
View File
@@ -9,6 +9,8 @@ class VisitCreate(BaseModel):
visit_date: str # "YYYY-MM-DD"
visit_method: str = "上门"
time_range: str = ""
visitor_name: str = ""
visitor_phone: str = ""
communication_content: str = ""
customer_demand: str = ""
companions: list[uuid.UUID] = []
@@ -20,6 +22,8 @@ class VisitUpdate(BaseModel):
visit_date: Optional[str] = None
visit_method: Optional[str] = None
time_range: Optional[str] = None
visitor_name: Optional[str] = None
visitor_phone: Optional[str] = None
communication_content: Optional[str] = None
customer_demand: Optional[str] = None
companions: Optional[list[uuid.UUID]] = None
@@ -32,6 +36,8 @@ class VisitOut(BaseModel):
visit_date: date
visit_method: str
time_range: str
visitor_name: str = ""
visitor_phone: str = ""
communication_content: str
customer_demand: str
companions: Optional[list[uuid.UUID]] = None
+3 -1
View File
@@ -40,7 +40,7 @@ async def export_weekly_report(db: AsyncSession, reference_date: date | None = N
# ── Sheet 1: 每日拜访记录 ──
ws1 = wb.active
ws1.title = "每日拜访记录"
headers1 = ["客户单位", "拜访日期", "拜访方式", "时间范围", "沟通内容", "客户需求", "同访人员", "客户经理"]
headers1 = ["客户单位", "拜访日期", "拜访方式", "时间范围", "拜访人姓名", "拜访人电话", "沟通内容", "客户需求", "同访人员", "客户经理"]
ws1.append(headers1)
for col in range(1, len(headers1) + 1):
cell = ws1.cell(row=1, column=col)
@@ -57,6 +57,8 @@ async def export_weekly_report(db: AsyncSession, reference_date: date | None = N
str(v.visit_date),
v.visit_method,
v.time_range,
v.visitor_name or "",
v.visitor_phone or "",
v.communication_content,
v.customer_demand,
", ".join(companions_names),
+5 -4
View File
@@ -28,24 +28,23 @@ async def import_from_excel(db: AsyncSession, file_bytes: bytes, manager_id: uui
if not row[0]:
continue
try:
cust_name, visit_date_str, visit_method, time_range, content, demand, companions_str, mgr_str = \
cust_name, visit_date_str, visit_method, time_range, visitor_name, visitor_phone, content, demand, companions_str, mgr_str = \
row[0], str(row[1]) if row[1] else str(date.today()), \
str(row[2]) if row[2] else "上门", str(row[3]) if row[3] else "", \
str(row[4]) if row[4] else "", str(row[5]) if row[5] else "", \
str(row[6]) if row[6] else "", str(row[7]) if row[7] else ""
str(row[6]) if row[6] else "", str(row[7]) if row[7] else "", \
str(row[8]) if row[8] else "", str(row[9]) if row[9] else ""
customer_id = customer_map.get(cust_name)
if not customer_id:
stats["skipped"] += 1
continue
# Parse date
try:
visit_date = datetime.strptime(visit_date_str[:10], "%Y-%m-%d").date()
except ValueError:
visit_date = date.today()
# Check for duplicates (same day, same person, same customer)
existing = await db.execute(
select(Visit).where(
Visit.visit_date == visit_date,
@@ -62,6 +61,8 @@ async def import_from_excel(db: AsyncSession, file_bytes: bytes, manager_id: uui
visit_date=visit_date,
visit_method=visit_method if visit_method in ["上门", "电话", "微信", "出差"] else "上门",
time_range=time_range,
visitor_name=visitor_name,
visitor_phone=visitor_phone,
communication_content=content,
customer_demand=demand,
manager_id=manager_id,
+1093
View File
File diff suppressed because it is too large Load Diff
+4
View File
@@ -16,7 +16,11 @@
"vue-router": "^4.5.0"
},
"devDependencies": {
"@tailwindcss/typography": "^0.5.20",
"@vitejs/plugin-vue": "^5.2.1",
"autoprefixer": "^10.5.0",
"postcss": "^8.5.15",
"tailwindcss": "^3.4.19",
"typescript": "~5.6.0",
"vite": "^6.0.5",
"vue-tsc": "^2.2.0"
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
+243 -6
View File
@@ -4,7 +4,6 @@ import { computed } from 'vue'
const auth = useAuthStore()
const isMobile = computed(() => {
// Simple mobile detection — can be refined
return /Android|iPhone|iPad|iPod|webOS/i.test(navigator.userAgent) || window.innerWidth < 768
})
</script>
@@ -14,11 +13,249 @@ const isMobile = computed(() => {
</template>
<style>
/* ═══════════════════════════════════════════════════════════
企迹 (qiji) — Editorial/Magazine Design System
Aesthetic: 编辑/杂志风格 | 墨色+朱砂+印泥金 | 站酷小薇体+思源宋体
═══════════════════════════════════════════════════════════ */
:root {
/* ── Core palette ── */
--ink: #1C3738;
--ink-light: #2A4F50;
--ink-dark: #0F1F20;
--vermilion: #B8472E;
--vermilion-light: #D46A4F;
--vermilion-dark: #8B3522;
--gold: #C4934A;
--gold-light: #D4AD6E;
--gold-dark: #A67736;
--paper: #F5F0E8;
--paper-dark: #EBE4D8;
--surface: #FFFFFF;
--sage: #4A6741;
--amber: #C68B3C;
--warm-gray: #7B7568;
--warm-border: #E5DFD3;
/* ── Semantic tokens ── */
--c-primary: var(--ink);
--c-primary-light: var(--ink-light);
--c-primary-bg: #EEF2F2;
--c-accent: var(--vermilion);
--c-accent-light: var(--vermilion-light);
--c-gold: var(--gold);
--c-success: var(--sage);
--c-warning: var(--amber);
--c-danger: var(--vermilion);
--c-info: #5B7FA5;
--c-text: #1A1A1A;
--c-text-secondary: var(--warm-gray);
--c-text-muted: #AAA59C;
--c-bg: var(--paper);
--c-bg-card: var(--surface);
--c-border: var(--warm-border);
/* ── Effects ── */
--shadow-sm: 0 1px 2px rgba(28,55,56,0.04);
--shadow: 0 2px 6px rgba(28,55,56,0.06);
--shadow-md: 0 6px 18px rgba(28,55,56,0.08);
--shadow-lg: 0 12px 36px rgba(28,55,56,0.10);
--radius: 2px;
--radius-sm: 2px;
--radius-xs: 2px;
--transition: 0.22s cubic-bezier(0.4, 0, 0.2, 1);
}
/* ── Reset & Base ── */
*, *::before, *::after { box-sizing: border-box; }
html, body, #app {
margin: 0;
padding: 0;
height: 100%;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial,
'PingFang SC', 'Microsoft YaHei', sans-serif;
margin: 0; padding: 0; height: 100%;
overflow-x: hidden;
-webkit-overflow-scrolling: touch;
font-family: 'Noto Serif SC', STSong, Songti SC, '宋体', serif;
font-size: 15px;
line-height: 1.7;
color: var(--c-text);
background: var(--c-bg);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* ── Editorial heading style ── */
h1, h2, h3, h4, h5, h6 {
font-family: 'ZCOOL XiaoWei', STSong, Songti SC, serif;
letter-spacing: 0.02em;
}
/* ═══════════════════════════════════════════════════════════
Element Plus Global Overrides — Editorial Remap
═══════════════════════════════════════════════════════════ */
/* ── Card: sharp corners, subtle border, refined shadow ── */
.el-card {
border-radius: 2px !important;
border: 1px solid var(--c-border) !important;
box-shadow: var(--shadow) !important;
transition: box-shadow var(--transition);
background: var(--surface) !important;
}
.el-card:hover { box-shadow: var(--shadow-md) !important; }
.el-card__header {
border-bottom: 2px solid var(--gold) !important;
padding: 16px 20px !important;
font-family: 'ZCOOL XiaoWei', STSong, serif;
font-weight: 600;
font-size: 16px;
color: var(--ink);
letter-spacing: 0.03em;
}
.el-card__body { padding: 20px !important; }
/* ── Button: sharp, decisive ── */
.el-button {
border-radius: 2px !important;
font-weight: 500;
letter-spacing: 0.02em;
transition: all var(--transition);
font-family: 'Noto Serif SC', STSong, Songti SC, serif;
}
.el-button--primary {
background: var(--ink) !important;
border-color: var(--ink) !important;
color: #fff !important;
}
.el-button--primary:hover {
background: var(--ink-light) !important;
border-color: var(--ink-light) !important;
}
.el-button--success {
background: var(--sage) !important;
border-color: var(--sage) !important;
}
.el-button--danger {
background: var(--vermilion) !important;
border-color: var(--vermilion) !important;
}
.el-button--warning {
background: var(--amber) !important;
border-color: var(--amber) !important;
}
/* ── Tag: editorial label style ── */
.el-tag {
border-radius: 2px !important;
font-weight: 500;
font-family: 'Noto Serif SC', STSong, serif;
letter-spacing: 0.02em;
}
/* ── Tabs ── */
.el-tabs__item {
font-weight: 500;
font-family: 'ZCOOL XiaoWei', STSong, serif;
}
.el-tabs__active-bar { background: var(--gold) !important; }
.el-tabs__item.is-active { color: var(--ink) !important; }
/* ── Table ── */
.el-table th.el-table__cell {
background: var(--c-primary-bg);
color: var(--ink);
font-weight: 600;
font-size: 13px;
font-family: 'ZCOOL XiaoWei', STSong, serif;
letter-spacing: 0.03em;
}
.el-table--striped .el-table__body tr.el-table__row--striped td.el-table__cell {
background: var(--c-primary-bg);
}
/* ── Pagination ── */
.el-pagination { margin-top: 16px; }
/* ── Dialog ── */
.el-dialog {
border-radius: 2px !important;
}
.el-dialog__header {
padding: 20px 24px 0 !important;
font-weight: 600;
font-family: 'ZCOOL XiaoWei', STSong, serif;
}
.el-dialog__body { padding: 20px 24px !important; }
/* ── Menu ── */
.el-menu { border-right: none !important; }
/* ── Progress ── */
.el-progress-bar__outer { border-radius: 0 !important; }
.el-progress-bar__inner { border-radius: 0 !important; background: var(--ink) !important; }
/* ── Form ── */
.el-form { max-width: 100%; }
.el-select, .el-date-editor { max-width: 100%; }
.el-table { max-width: 100%; }
.el-table__body-wrapper { overflow-x: auto; }
.el-radio-button__inner {
min-height: 36px;
line-height: 36px;
border-radius: 2px !important;
}
.el-radio-button__orig-radio:checked + .el-radio-button__inner {
background: var(--ink) !important;
border-color: var(--ink) !important;
}
/* ── Input & Select borders ── */
.el-input__wrapper {
border-radius: 2px !important;
box-shadow: 0 0 0 1px var(--c-border) inset !important;
}
.el-input__wrapper:hover { box-shadow: 0 0 0 1px var(--ink) inset !important; }
.el-input__wrapper.is-focus { box-shadow: 0 0 0 2px var(--ink) inset !important; }
.el-select .el-input__wrapper { border-radius: 2px !important; }
/* ── Date picker ── */
.el-date-editor.el-input { border-radius: 2px !important; }
/* ── Textarea ── */
.el-textarea__inner {
border-radius: 2px !important;
font-family: 'Noto Serif SC', STSong, serif;
}
/* ── Radio ── */
.el-radio__input.is-checked .el-radio__inner {
background: var(--ink) !important;
border-color: var(--ink) !important;
}
.el-radio__input.is-checked + .el-radio__label { color: var(--ink) !important; }
/* ── Checkbox ── */
.el-checkbox__input.is-checked .el-checkbox__inner {
background: var(--ink) !important;
border-color: var(--ink) !important;
}
/* ── Switch ── */
.el-switch.is-checked .el-switch__core {
background: var(--ink) !important;
border-color: var(--ink) !important;
}
/* ── Loading spinner ── */
.el-loading-spinner .circular circle { stroke: var(--gold) !important; }
/* ── Message box ── */
.el-message-box { border-radius: 2px !important; }
/* ── Notification ── */
.el-notification { border-radius: 2px !important; }
/* ── Divider ── */
.el-divider__text {
font-family: 'ZCOOL XiaoWei', STSong, serif;
color: var(--warm-gray);
}
</style>
+241 -44
View File
@@ -1,55 +1,89 @@
<script setup lang="ts">
import { useRoute, useRouter } from 'vue-router'
import { computed } from 'vue'
import { computed, ref } from 'vue'
import { useAuthStore } from '@/stores/auth'
const route = useRoute()
const router = useRouter()
const auth = useAuthStore()
const collapsed = ref(false)
const menuItems = computed(() => {
const items = [
{ path: '/', label: '仪表盘', icon: 'Odometer' },
{ path: '/weekly-report', label: '周报详情', icon: 'Document' },
const items: { path: string; label: string; icon: string }[] = [
{ path: '/', label: '仪表盘', icon: '<polyline points="4 7 12 3 20 7"></polyline><polyline points="20 7 20 21 4 21 4 7"></polyline><line x1="8" y1="21" x2="8" y2="12"></line><line x1="16" y1="21" x2="16" y2="12"></line>' },
{ path: '/weekly-report', label: '周报详情', icon: '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path><polyline points="14 2 14 8 20 8"></polyline><line x1="16" y1="13" x2="8" y2="13"></line><line x1="16" y1="17" x2="8" y2="17"></line>' },
]
if (auth.isManager || auth.isDirector) {
items.push({ path: '/workspace', label: '我的数据', icon: 'List' })
items.push({ path: '/workspace', label: '我的数据', icon: '<line x1="8" y1="6" x2="21" y2="6"></line><line x1="8" y1="12" x2="21" y2="12"></line><line x1="8" y1="18" x2="21" y2="18"></line><line x1="3" y1="6" x2="3.01" y2="6"></line><line x1="3" y1="12" x2="3.01" y2="12"></line><line x1="3" y1="18" x2="3.01" y2="18"></line>' })
}
items.push({ path: '/customers', label: '客户管理', icon: 'UserFilled' })
items.push({ path: '/customers', label: '客户管理', icon: '<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path><circle cx="9" cy="7" r="4"></circle>' })
if (auth.isDirector) {
items.push({ path: '/users', label: '用户管理', icon: 'Avatar' })
items.push({ path: '/settings', label: '系统设置', icon: 'Setting' })
items.push({ path: '/users', label: '用户管理', icon: '<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path><circle cx="9" cy="7" r="4"></circle><path d="M23 21v-2a4 4 0 0 0-3-3.87"></path><path d="M16 3.13a4 4 0 0 1 0 7.75"></path>' })
items.push({ path: '/settings', label: '系统设置', icon: '<circle cx="12" cy="12" r="3"></circle><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path>' })
}
return items
})
const roleLabel = computed(() => {
if (auth.isDirector) return '支局长'
if (auth.isLeader) return '分管领导'
return '客户经理'
})
</script>
<template>
<div class="desktop-layout">
<aside class="sidebar">
<div class="logo">
<h2>企迹</h2>
<p>政企周报管理</p>
<!-- Sidebar Ink & Gold Editorial -->
<aside class="sidebar" :class="{ 'sidebar--collapsed': collapsed }">
<div class="sidebar-brand">
<h1 class="brand-title">企迹</h1>
<p class="brand-sub">政企周报管理</p>
<div class="brand-rule"></div>
</div>
<nav class="sidebar-nav">
<router-link
v-for="item in menuItems"
:key="item.path"
:to="item.path"
class="nav-item"
:class="{ 'nav-item--active': route.path === item.path }"
>
<svg class="nav-icon" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" v-html="item.icon"></svg>
<span class="nav-label">{{ item.label }}</span>
</router-link>
</nav>
<div class="sidebar-footer">
<span class="footer-role">{{ roleLabel }}</span>
<span class="footer-name">{{ auth.name }}</span>
</div>
<el-menu
:default-active="route.path"
router
background-color="#304156"
text-color="#bfcbd9"
active-text-color="#409EFF"
>
<el-menu-item v-for="item in menuItems" :key="item.path" :index="item.path">
<el-icon><component :is="item.icon" /></el-icon>
<span>{{ item.label }}</span>
</el-menu-item>
</el-menu>
</aside>
<!-- Main Area -->
<div class="main-area">
<header class="topbar">
<span class="greeting">{{ auth.isDirector ? '支局长' : auth.isLeader ? '分管领导' : '客户经理' }} {{ auth.name }}</span>
<div style="display:flex; align-items:center; gap:8px">
<el-button text @click="router.push('/m')">📱 移动端</el-button>
<el-button text @click="auth.logout(); router.push('/login')">退出</el-button>
<button class="collapse-btn" @click="collapsed = !collapsed" :title="collapsed ? '展开菜单' : '折叠菜单'">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline :points="collapsed ? '13 17 18 12 13 7' : '11 7 6 12 11 17'"></polyline>
</svg>
</button>
<div class="topbar-actions">
<button class="topbar-btn" @click="router.push('/m')" title="移动端">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="5" y="2" width="14" height="20" rx="2" ry="2"></rect>
<line x1="12" y1="18" x2="12.01" y2="18"></line>
</svg>
移动端
</button>
<button class="topbar-btn topbar-btn--logout" @click="auth.logout(); router.push('/login')">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"></path>
<polyline points="16 17 21 12 16 7"></polyline>
<line x1="21" y1="12" x2="9" y2="12"></line>
</svg>
退出
</button>
</div>
</header>
<main class="content">
@@ -60,42 +94,205 @@ const menuItems = computed(() => {
</template>
<style scoped>
/* ═══ Layout ═══ */
.desktop-layout {
display: flex;
height: 100vh;
overflow: hidden;
}
/* ═══ Sidebar ═══ */
.sidebar {
width: 220px;
background: #304156;
color: white;
width: 240px;
background: var(--ink);
color: #fff;
display: flex;
flex-direction: column;
flex-shrink: 0;
overflow: hidden;
transition: width 0.25s ease;
}
.logo {
padding: 20px 16px;
text-align: center;
border-bottom: 1px solid #4a5a6a;
.sidebar--collapsed {
width: 64px;
}
.logo h2 { margin: 0; font-size: 18px; color: #fff; }
.logo p { margin: 4px 0 0; font-size: 11px; color: #bfcbd9; }
.sidebar--collapsed .brand-sub,
.sidebar--collapsed .brand-rule,
.sidebar--collapsed .nav-label,
.sidebar--collapsed .footer-role,
.sidebar--collapsed .footer-name { display: none; }
.sidebar--collapsed .brand-title { font-size: 20px; text-align: center; }
.sidebar--collapsed .nav-item { justify-content: center; padding: 11px 0; }
.sidebar--collapsed .nav-icon { font-size: 20px; margin: 0; }
.sidebar--collapsed .sidebar-footer { text-align: center; padding: 10px; }
.sidebar-brand {
padding: 26px 22px 18px;
border-bottom: 1px solid rgba(255,255,255,0.06);
}
.brand-title {
margin: 0;
font-family: 'ZCOOL XiaoWei', STSong, Songti SC, serif;
font-size: 24px;
font-weight: 400;
letter-spacing: 0.12em;
color: #fff;
line-height: 1.2;
}
.brand-sub {
margin: 2px 0 0;
font-family: 'Noto Serif SC', STSong, serif;
font-size: 10px;
color: rgba(255,255,255,0.4);
letter-spacing: 0.1em;
}
.brand-rule {
width: 28px;
height: 3px;
background: var(--gold);
margin-top: 14px;
}
/* ═══ Navigation ═══ */
.sidebar-nav {
flex: 1;
padding: 12px 10px;
display: flex;
flex-direction: column;
gap: 2px;
}
.nav-item {
display: flex;
align-items: center;
gap: 10px;
padding: 11px 14px;
color: rgba(255,255,255,0.55);
text-decoration: none;
font-family: 'Noto Serif SC', STSong, serif;
font-size: 14px;
letter-spacing: 0.04em;
transition: all 0.22s;
border-left: 2px solid transparent;
}
.nav-item:hover {
color: rgba(255,255,255,0.85);
background: rgba(255,255,255,0.04);
}
.nav-item--active {
color: #fff;
background: rgba(255,255,255,0.06);
border-left-color: var(--gold);
}
.nav-icon {
font-size: 18px;
flex-shrink: 0;
}
.nav-label {
font-weight: 500;
}
/* ═══ Sidebar Footer ═══ */
.sidebar-footer {
padding: 14px 22px;
border-top: 1px solid rgba(255,255,255,0.06);
display: flex;
flex-direction: column;
gap: 2px;
}
.footer-role {
font-family: 'Noto Serif SC', STSong, serif;
font-size: 10px;
color: var(--gold);
letter-spacing: 0.1em;
}
.footer-name {
font-family: 'ZCOOL XiaoWei', STSong, serif;
font-size: 13px;
color: rgba(255,255,255,0.7);
letter-spacing: 0.04em;
}
/* ═══ Main Area ═══ */
.main-area {
flex: 1;
display: flex;
flex-direction: column;
background: #f0f2f5;
background: var(--paper);
min-width: 0;
}
/* ═══ Topbar ═══ */
.topbar {
background: white;
padding: 12px 24px;
background: var(--surface);
padding: 10px 24px;
display: flex;
justify-content: flex-end;
justify-content: space-between;
align-items: center;
gap: 16px;
box-shadow: 0 1px 4px rgba(0,0,0,0.05);
border-bottom: 1px solid var(--warm-border);
flex-shrink: 0;
}
.topbar-greeting {
font-family: 'ZCOOL XiaoWei', STSong, serif;
font-size: 14px;
color: var(--ink);
letter-spacing: 0.04em;
}
.collapse-btn {
display: flex; align-items: center; justify-content: center;
width: 28px; height: 28px;
background: none; border: none;
color: var(--warm-gray); cursor: pointer;
transition: all 0.2s; flex-shrink: 0; margin-right: 8px;
opacity: 0.5;
}
.collapse-btn:hover { color: var(--ink); opacity: 1; }
.topbar-actions {
display: flex;
align-items: center;
gap: 8px;
}
.topbar-btn {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 6px 12px;
background: none;
border: 1px solid var(--warm-border);
color: var(--warm-gray);
font-family: 'Noto Serif SC', STSong, serif;
font-size: 12px;
letter-spacing: 0.03em;
cursor: pointer;
transition: all 0.2s;
}
.topbar-btn:hover {
color: var(--ink);
border-color: var(--ink);
}
.topbar-btn--logout:hover {
color: var(--vermilion);
border-color: var(--vermilion);
}
/* ═══ Content ═══ */
.content {
flex: 1;
overflow-y: auto;
padding: 20px;
padding: 24px;
}
</style>
+223 -34
View File
@@ -8,10 +8,23 @@ const router = useRouter()
const auth = useAuthStore()
const tabs = [
{ path: '/m', label: '首页', icon: 'HomeFilled' },
{ path: '/m/visit/new', label: '今日拜访', icon: 'Edit' },
{ path: '/m/note/new', label: '今日纪要', icon: 'Notebook' },
{
path: '/m',
label: '首页',
icon: 'home',
},
{
path: '/m/visit/new',
label: '拜访',
icon: 'visit',
},
{
path: '/m/note/new',
label: '纪要',
icon: 'note',
},
]
const activeTab = computed(() => {
if (route.path === '/m') return '/m'
if (route.path.includes('/visit')) return '/m/visit/new'
@@ -22,71 +35,247 @@ const activeTab = computed(() => {
<template>
<div class="mobile-layout">
<!-- Magazine Header -->
<header class="mobile-header">
<span class="header-title">企迹</span>
<div style="display:flex; align-items:center; gap:8px">
<el-button text size="small" style="color:white" @click="router.push('/')">💻 PC</el-button>
<div class="header-brand">
<h1 class="header-title">企迹</h1>
<span class="header-subtitle">政企周报</span>
<div class="header-rule"></div>
</div>
<div class="header-actions">
<button class="header-pc-btn" @click="router.push('/')" title="PC版">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="2" y="3" width="20" height="14" rx="2" ry="2"></rect>
<line x1="8" y1="21" x2="16" y2="21"></line>
<line x1="12" y1="17" x2="12" y2="21"></line>
</svg>
</button>
<span class="header-user">{{ auth.name }}</span>
</div>
</header>
<!-- Main Content -->
<main class="mobile-main">
<!-- Gold corner accent (decorative) -->
<div class="page-accent"></div>
<router-view />
</main>
<!-- Floating Pill TabBar -->
<nav class="mobile-tabbar">
<div
v-for="tab in tabs" :key="tab.path"
class="tab-item" :class="{ active: activeTab === tab.path }"
@click="router.push(tab.path)"
>
<el-icon><component :is="tab.icon" /></el-icon>
<span>{{ tab.label }}</span>
<div class="tabbar-pill">
<div
v-for="tab in tabs"
:key="tab.path"
class="tab-item"
:class="{ active: activeTab === tab.path }"
@click="router.push(tab.path)"
>
<!-- Home icon -->
<svg v-if="tab.icon === 'home'" class="tab-icon" width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"></path>
<polyline points="9 22 9 12 15 12 15 22"></polyline>
</svg>
<!-- Visit icon -->
<svg v-else-if="tab.icon === 'visit'" class="tab-icon" width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<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"></path>
<rect x="8" y="2" width="8" height="4" rx="1" ry="1"></rect>
<line x1="12" y1="11" x2="12" y2="17"></line>
<line x1="9" y1="14" x2="15" y2="14"></line>
</svg>
<!-- Note icon -->
<svg v-else-if="tab.icon === 'note'" class="tab-icon" width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path>
<polyline points="14 2 14 8 20 8"></polyline>
<line x1="16" y1="13" x2="8" y2="13"></line>
<line x1="16" y1="17" x2="8" y2="17"></line>
<polyline points="10 9 9 9 8 9"></polyline>
</svg>
<span class="tab-label">{{ tab.label }}</span>
</div>
</div>
</nav>
</div>
</template>
<style scoped>
/* ═══ Layout ═══ */
.mobile-layout {
display: flex;
flex-direction: column;
height: 100vh;
background: #f5f7fa;
height: 100dvh;
width: 100%;
overflow: hidden;
background: var(--paper);
position: relative;
}
/* ═══ Header — Editorial Style ═══ */
.mobile-header {
background: linear-gradient(135deg, #1a73e8, #4080ff);
color: white;
padding: 12px 16px;
display: flex;
justify-content: space-between;
align-items: center;
font-size: 16px;
align-items: flex-start;
padding: 18px 20px 10px;
background: var(--paper);
flex-shrink: 0;
position: relative;
}
.header-brand {
display: flex;
flex-direction: column;
gap: 2px;
}
.header-title {
margin: 0;
font-family: 'ZCOOL XiaoWei', STSong, Songti SC, serif;
font-size: 26px;
font-weight: 400;
color: var(--ink);
letter-spacing: 0.08em;
line-height: 1.2;
}
.header-subtitle {
font-family: 'Noto Serif SC', STSong, Songti SC, serif;
font-size: 11px;
color: var(--warm-gray);
letter-spacing: 0.12em;
text-transform: uppercase;
margin-left: 1px;
}
.header-rule {
width: 28px;
height: 3px;
background: var(--gold);
margin-top: 8px;
}
.header-actions {
display: flex;
align-items: center;
gap: 10px;
padding-top: 2px;
}
.header-pc-btn {
background: none;
border: 1px solid var(--warm-border);
border-radius: 2px;
padding: 4px 6px;
cursor: pointer;
color: var(--warm-gray);
display: flex;
align-items: center;
transition: all var(--transition);
}
.header-pc-btn:hover {
color: var(--ink);
border-color: var(--ink);
}
.header-user {
font-family: 'Noto Serif SC', STSong, serif;
font-size: 13px;
color: var(--warm-gray);
letter-spacing: 0.03em;
}
/* ═══ Main Scroll Area ═══ */
.mobile-main {
flex: 1;
overflow-y: auto;
padding: 12px;
overflow-x: hidden;
padding: 0 16px 20px;
-webkit-overflow-scrolling: touch;
position: relative;
}
/* Decorative accent — subtle diagonal line in corner */
.page-accent {
position: fixed;
top: 60px;
right: 0;
width: 60px;
height: 60px;
background: linear-gradient(135deg, transparent 60%, rgba(196,147,74,0.06) 60%);
pointer-events: none;
z-index: 0;
}
/* ═══ Floating Pill TabBar ═══ */
.mobile-tabbar {
display: flex;
background: white;
border-top: 1px solid #e4e7ed;
padding: 6px 0;
justify-content: center;
padding: 0 16px 12px;
padding-bottom: calc(12px + env(safe-area-inset-bottom, 0px));
flex-shrink: 0;
position: relative;
z-index: 10;
}
.tab-item {
flex: 1;
.tabbar-pill {
display: flex;
background: var(--surface);
border: 1px solid var(--warm-border);
border-radius: 40px;
padding: 6px 8px;
box-shadow: 0 4px 18px rgba(28,55,56,0.08);
gap: 4px;
}
.tab-item {
display: flex;
flex-direction: column;
align-items: center;
padding: 6px 0;
color: #909399;
font-size: 12px;
gap: 6px;
padding: 8px 16px;
border-radius: 32px;
color: var(--warm-gray);
font-size: 13px;
cursor: pointer;
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
white-space: nowrap;
font-family: 'Noto Serif SC', STSong, serif;
letter-spacing: 0.03em;
}
.tab-item:active {
transform: scale(0.95);
}
.tab-item.active {
color: #1a73e8;
background: var(--ink);
color: #fff;
box-shadow: 0 2px 8px rgba(28,55,56,0.25);
}
.tab-item .el-icon {
font-size: 22px;
margin-bottom: 2px;
.tab-item.active .tab-icon {
stroke: #fff;
}
.tab-icon {
flex-shrink: 0;
transition: stroke 0.25s;
}
.tab-label {
font-weight: 500;
}
/* Active tab has gold accent dot */
.tab-item.active .tab-label::after {
content: '';
display: inline-block;
width: 4px;
height: 4px;
background: var(--gold);
border-radius: 50%;
margin-left: 6px;
vertical-align: middle;
position: relative;
top: -1px;
}
</style>
+1
View File
@@ -3,6 +3,7 @@ import { createPinia } from 'pinia'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import zhCn from 'element-plus/dist/locale/zh-cn.mjs'
import './tailwind.css'
import App from './App.vue'
import router from './router'
+34
View File
@@ -0,0 +1,34 @@
/* ── Google Fonts import for Editorial Chinese fonts ── */
@import url('https://fonts.googleapis.com/css2?family=ZCOOL+XiaoWei&family=Noto+Serif+SC:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap');
@tailwind base;
@tailwind components;
@tailwind utilities;
/* ── Base layer custom styles ── */
@layer base {
:root {
--ink: #1C3738;
--ink-light: #2A4F50;
--vermilion: #B8472E;
--vermilion-light: #D46A4F;
--gold: #C4934A;
--gold-light: #D4AD6E;
--paper: #F5F0E8;
--paper-dark: #EBE4D8;
--sage: #4A6741;
--amber: #C68B3C;
--warm-gray: #7B7568;
--warm-border: #E5DFD3;
--text-primary: #1A1A1A;
}
html, body, #app {
margin: 0;
padding: 0;
height: 100%;
overflow-x: hidden;
-webkit-overflow-scrolling: touch;
background: var(--paper);
}
}
+31 -10
View File
@@ -92,14 +92,29 @@ function goCasdoorLogin() {
justify-content: center;
align-items: center;
height: 100vh;
background: linear-gradient(135deg, #1a73e8 0%, #4080ff 50%, #69a0ff 100%);
background: linear-gradient(135deg, #4f6ef7 0%, #7b93fa 40%, #a5b4fc 100%);
position: relative;
overflow: hidden;
}
.login-page::before {
content: ''; position: absolute; width: 600px; height: 600px;
background: rgba(255,255,255,0.05); border-radius: 50%;
top: -200px; right: -200px;
}
.login-page::after {
content: ''; position: absolute; width: 400px; height: 400px;
background: rgba(255,255,255,0.04); border-radius: 50%;
bottom: -100px; left: -100px;
}
.login-card {
background: white;
border-radius: 16px;
border-radius: 20px;
padding: 48px 40px;
width: 360px;
box-shadow: 0 20px 60px rgba(0,0,0,0.2);
width: 380px;
max-width: 90vw;
box-shadow: 0 25px 60px rgba(0,0,0,0.15);
position: relative;
z-index: 1;
}
.brand {
text-align: center;
@@ -107,18 +122,24 @@ function goCasdoorLogin() {
}
.brand h1 {
margin: 0;
font-size: 32px;
color: #1a73e8;
font-size: 36px;
font-weight: 800;
letter-spacing: 4px;
background: linear-gradient(135deg, #4f6ef7, #7b93fa);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.brand p {
margin: 8px 0 0;
color: #909399;
margin: 10px 0 0;
color: var(--c-text-secondary);
font-size: 14px;
letter-spacing: 1px;
}
.hint {
text-align: center;
color: #c0c4cc;
color: var(--c-text-muted);
font-size: 12px;
margin-top: 16px;
margin-top: 20px;
}
</style>
+82 -96
View File
@@ -30,16 +30,13 @@ const existingContacts = ref<any[]>([])
const feeUnits = ['元/月', '元/年', '自定义']
const feeCustomUnit = ref('')
// Detail
const detailVisible = ref(false)
const detailCustomer = ref<any>(null)
// Managers
const managers = ref<any[]>([])
const selectedIds = ref<string[]>([])
const batchManagerId = ref('')
// Import
const importDialogVisible = ref(false)
const importFile = ref<File | null>(null)
const importLoading = ref(false)
@@ -48,18 +45,15 @@ const importResult = ref<any>(null)
onMounted(async () => {
await loadCustomers()
try {
const res = await api.get('/users/', { params: { role: 'manager' } })
managers.value = res.data
const res = await api.get('/users/')
managers.value = (res.data || []).filter((u: any) => u.role !== 'leader')
} catch (_) {}
})
async function loadCustomers() {
loading.value = true
try {
const params: any = {
page: currentPage.value,
page_size: pageSize.value,
}
const params: any = { page: currentPage.value, page_size: pageSize.value }
if (search.value) params.search = search.value
if (filterIndustry.value) params.industry = filterIndustry.value
if (filterService.value) params.service = filterService.value
@@ -67,17 +61,14 @@ async function loadCustomers() {
const res = await customersApi.list(params)
customers.value = res.data.items
total.value = res.data.total
} catch (e: any) {
ElMessage.error('加载失败')
} finally { loading.value = false }
} catch (e: any) { ElMessage.error('加载失败') }
finally { loading.value = false }
}
function onPageChange(page: number) { currentPage.value = page; loadCustomers() }
function onPageSizeChange(size: number) { pageSize.value = size; currentPage.value = 1; loadCustomers() }
function onFilterChange() { currentPage.value = 1; loadCustomers() }
// ── Form helpers ──
function buildMonthlyFee(): string {
const amt = form.value.fee_amount.trim()
if (!amt) return ''
@@ -92,35 +83,20 @@ function parseMonthlyFee(fee: string) {
feeCustomUnit.value = ''
if (!fee) { form.value.fee_amount = ''; form.value.fee_unit = '元/月'; return }
for (const u of ['元/月', '元/年']) {
if (fee.endsWith(u)) {
form.value.fee_amount = fee.slice(0, -u.length).trim()
form.value.fee_unit = u
return
}
if (fee.endsWith(u)) { form.value.fee_amount = fee.slice(0, -u.length).trim(); form.value.fee_unit = u; return }
}
// Try to separate trailing non-numeric chars as custom unit
const m = fee.match(/^(.+?)\s*([^\d]+)$/)
if (m) {
form.value.fee_amount = m[1].trim()
feeCustomUnit.value = m[2].trim()
form.value.fee_unit = '自定义'
} else {
form.value.fee_amount = fee
form.value.fee_unit = '自定义'
}
if (m) { form.value.fee_amount = m[1].trim(); feeCustomUnit.value = m[2].trim(); form.value.fee_unit = '自定义' }
else { form.value.fee_amount = fee; form.value.fee_unit = '自定义' }
}
// ── Create / Edit Dialog ──
function resetForm() {
form.value = { name: '', industry: '', address: '', in_use_services: '', fee_amount: '', fee_unit: '元/月', remarks: '', contacts: [], assignee_id: '' }
feeCustomUnit.value = ''
existingContacts.value = []
}
function openCreate() {
dialogTitle.value = '新建客户'; editId.value = ''; resetForm(); dialogVisible.value = true
}
function openCreate() { dialogTitle.value = '新建客户'; editId.value = ''; resetForm(); dialogVisible.value = true }
async function openEdit(customer: any) {
dialogTitle.value = '编辑客户'; editId.value = customer.id
@@ -145,9 +121,7 @@ async function handleSubmit() {
if (form.value.assignee_id) body.assignee_id = form.value.assignee_id
await customersApi.update(editId.value, body)
for (const c of form.value.contacts) {
if (c.name.trim()) {
await api.post(`/customers/${editId.value}/contacts`, { name: c.name.trim(), phone: c.phone.trim(), role_desc: c.role_desc.trim() })
}
if (c.name.trim()) await api.post(`/customers/${editId.value}/contacts`, { name: c.name.trim(), phone: c.phone.trim(), role_desc: c.role_desc.trim() })
}
ElMessage.success('已更新')
} else {
@@ -168,17 +142,16 @@ async function removeExistingContact(contactId: string) {
} catch (e: any) { if (e !== 'cancel') ElMessage.error('删除失败') }
}
// ── Detail ──
async function openDetail(customer: any) {
try {
const res = await customersApi.get(customer.id)
detailCustomer.value = res.data
detailVisible.value = true
} catch (_) {}
const mgrColors = ['#1C3738','#4A6741','#5B7FA5','#7B7568','#8B6F47','#6B5B4F','#3D5A5C','#5C4A3D']
function mgrColor(name: string) {
if (!name) return '#909399'
let h = 0; for (let i=0;i<name.length;i++) h = name.charCodeAt(i) + ((h<<5)-h)
return mgrColors[Math.abs(h) % mgrColors.length]
}
// ── Delete ──
async function openDetail(customer: any) {
try { const res = await customersApi.get(customer.id); detailCustomer.value = res.data; detailVisible.value = true } catch (_) {}
}
async function handleDelete(customer: any) {
try {
@@ -189,8 +162,6 @@ async function handleDelete(customer: any) {
} catch (e: any) { if (e !== 'cancel') ElMessage.error('删除失败') }
}
// ── Batch Assign ──
function onSelectionChange(rows: any[]) { selectedIds.value = rows.map(r => r.id) }
async function handleBatchAssign() {
@@ -204,8 +175,6 @@ async function handleBatchAssign() {
} catch (e: any) { ElMessage.error('批量分配失败') }
}
// ── Import / Export ──
async function handleExport() {
try {
const res = await api.get('/customers/export', { responseType: 'blob' })
@@ -242,14 +211,38 @@ async function handleImport() {
<template>
<div class="customer-manage" v-loading="loading">
<div class="page-header">
<h3>客户档案管理</h3>
<div class="header-btns">
<el-button v-if="auth.isDirector" @click="downloadTemplate">📋 模板</el-button>
<el-button v-if="auth.isDirector" @click="openImportDialog">📤 导入</el-button>
<el-button v-if="auth.isDirector" @click="handleExport">📥 导出</el-button>
<el-button v-if="auth.isDirector" type="primary" @click="openCreate">+ 新建客户</el-button>
<!-- Page Header -->
<div class="page-head">
<div class="page-head-row">
<h2 class="page-title">客户档案管理</h2>
<div class="header-btns">
<el-button v-if="auth.isDirector" @click="downloadTemplate">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-right:4px">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path>
<polyline points="14 2 14 8 20 8"></polyline>
</svg>
模板
</el-button>
<el-button v-if="auth.isDirector" @click="openImportDialog">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-right:4px">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
<polyline points="17 8 12 3 7 8"></polyline>
<line x1="12" y1="3" x2="12" y2="15"></line>
</svg>
导入
</el-button>
<el-button v-if="auth.isDirector" @click="handleExport">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-right:4px">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
<polyline points="7 10 12 15 17 10"></polyline>
<line x1="12" y1="15" x2="12" y2="3"></line>
</svg>
导出
</el-button>
<el-button v-if="auth.isDirector" type="primary" @click="openCreate">+ 新建客户</el-button>
</div>
</div>
<div class="page-rule"></div>
</div>
<!-- Search & Filter -->
@@ -267,12 +260,12 @@ async function handleImport() {
<el-input v-model="filterService" placeholder="在用业务筛选" clearable @change="onFilterChange" />
</el-col>
<el-col :span="4">
<el-select v-model="filterManagerId" placeholder="客户经理筛选" clearable @change="onFilterChange" style="width:100%">
<el-select v-model="filterManagerId" placeholder="负责人筛选" clearable @change="onFilterChange" style="width:100%">
<el-option v-for="m in managers" :key="m.id" :label="m.name" :value="m.id" />
</el-select>
</el-col>
<el-col :span="7" v-if="auth.isDirector && selectedIds.length > 0">
<span style="margin-right:8px; color:#606266">已选 {{ selectedIds.length }} </span>
<span style="margin-right:8px; color: var(--warm-gray)">已选 {{ selectedIds.length }} </span>
<el-select v-model="batchManagerId" placeholder="目标客户经理" style="width:160px">
<el-option v-for="m in managers" :key="m.id" :label="m.name" :value="m.id" />
</el-select>
@@ -293,23 +286,18 @@ async function handleImport() {
</el-table-column>
<el-table-column prop="industry" label="行业" width="100" />
<el-table-column prop="in_use_services" label="在用业务" min-width="150" />
<el-table-column label="操作" width="160" fixed="right">
<el-table-column label="客户经理" width="90">
<template #default="{ row }">
<el-button text size="small" @click="openEdit(row)">编辑</el-button>
<el-button v-if="auth.isDirector" text size="small" type="danger" @click="handleDelete(row)">删除</el-button>
<el-tag :color="mgrColor(row.primary_manager_name)" effect="dark" size="small" style="border:none;color:#fff">{{ row.primary_manager_name || '-' }}</el-tag>
</template>
</el-table-column>
</el-table>
<div style="display:flex; justify-content:flex-end; margin-top:16px">
<el-pagination
v-model:current-page="currentPage"
v-model:page-size="pageSize"
:page-sizes="[25, 50, 100]"
:total="total"
layout="total, sizes, prev, pager, next, jumper"
background
@current-change="onPageChange"
@size-change="onPageSizeChange"
v-model:current-page="currentPage" v-model:page-size="pageSize"
:page-sizes="[25, 50, 100]" :total="total"
layout="total, sizes, prev, pager, next, jumper" background
@current-change="onPageChange" @size-change="onPageSizeChange"
/>
</div>
</el-card>
@@ -317,36 +305,22 @@ async function handleImport() {
<!-- Create/Edit Dialog -->
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="520px">
<el-form label-position="top">
<el-form-item label="单位名称" required>
<el-input v-model="form.name" placeholder="请输入单位名称" />
</el-form-item>
<el-form-item label="所属行业">
<el-input v-model="form.industry" placeholder="如: 教育、医疗、政府..." />
</el-form-item>
<el-form-item label="单位地址">
<el-input v-model="form.address" />
</el-form-item>
<el-form-item label="在用业务">
<el-input v-model="form.in_use_services" placeholder="如: 云桌面、专线、视频会议" />
</el-form-item>
<el-form-item label="单位名称" required><el-input v-model="form.name" placeholder="请输入单位名称" /></el-form-item>
<el-form-item label="所属行业"><el-input v-model="form.industry" placeholder="如: 教育、医疗、政府..." /></el-form-item>
<el-form-item label="单位地址"><el-input v-model="form.address" /></el-form-item>
<el-form-item label="在用业务"><el-input v-model="form.in_use_services" placeholder="如: 云桌面、专线、视频会议" /></el-form-item>
<el-form-item label="收支费用">
<el-row :gutter="8">
<el-col :span="8">
<el-input v-model="form.fee_amount" placeholder="金额" />
</el-col>
<el-col :span="8"><el-input v-model="form.fee_amount" placeholder="金额" /></el-col>
<el-col :span="form.fee_unit === '自定义' ? 8 : 16">
<el-select v-model="form.fee_unit" style="width:100%" @change="form.fee_unit !== '自定义' && (feeCustomUnit = '')">
<el-option v-for="u in feeUnits" :key="u" :label="u" :value="u" />
</el-select>
</el-col>
<el-col v-if="form.fee_unit === '自定义'" :span="8">
<el-input v-model="feeCustomUnit" placeholder="单位" />
</el-col>
<el-col v-if="form.fee_unit === '自定义'" :span="8"><el-input v-model="feeCustomUnit" placeholder="单位" /></el-col>
</el-row>
</el-form-item>
<el-form-item label="备注">
<el-input v-model="form.remarks" type="textarea" :rows="2" placeholder="备注信息..." />
</el-form-item>
<el-form-item label="备注"><el-input v-model="form.remarks" type="textarea" :rows="2" placeholder="备注信息..." /></el-form-item>
<el-form-item label="归属客户经理" v-if="auth.isDirector">
<el-select v-model="form.assignee_id" :placeholder="editId ? '留空则不修改' : '不选则默认分配给自己'" clearable style="width:100%">
<el-option v-for="m in managers" :key="m.id" :label="m.name" :value="m.id" />
@@ -355,7 +329,7 @@ async function handleImport() {
<el-form-item label="联系人">
<div v-if="editId && existingContacts.length" style="margin-bottom:8px">
<el-tag v-for="c in existingContacts" :key="c.id" closable @close="removeExistingContact(c.id)" style="margin: 2px 4px">
{{ c.name }}{{ c.phone ? ' 📞'+c.phone : '' }}{{ c.role_desc ? ' ('+c.role_desc+')' : '' }}
{{ c.name }}{{ c.phone ? ' · '+c.phone : '' }}{{ c.role_desc ? ' ('+c.role_desc+')' : '' }}
</el-tag>
</div>
<div v-for="(c, idx) in form.contacts" :key="'new-'+idx" style="display:flex; gap:8px; margin-bottom:4px;">
@@ -385,13 +359,18 @@ async function handleImport() {
<el-descriptions-item label="备注">{{ detailCustomer.remarks || '-' }}</el-descriptions-item>
<el-descriptions-item label="客户经理">{{ detailCustomer.primary_manager_name || '-' }}</el-descriptions-item>
</el-descriptions>
<h4 style="margin-top:16px">联系人</h4>
<h4 style="margin-top:16px; font-family: 'ZCOOL XiaoWei', STSong, serif; color: var(--ink)">联系人</h4>
<div v-if="detailCustomer.contacts?.length">
<el-tag v-for="c in detailCustomer.contacts" :key="c.id" style="margin:4px">
{{ c.name }}{{ c.phone ? ' 📞'+c.phone : '' }}{{ c.role_desc ? ' ('+c.role_desc+')' : '' }}
{{ c.name }}{{ c.phone ? ' · '+c.phone : '' }}{{ c.role_desc ? ' ('+c.role_desc+')' : '' }}
</el-tag>
</div>
<div v-else style="color:#c0c4cc; text-align:center; padding:12px">暂无联系人</div>
<div v-else style="color: var(--c-text-muted); text-align:center; padding:12px">暂无联系人</div>
</template>
<template #footer>
<el-button v-if="!auth.isLeader" type="primary" @click="detailVisible=false; openEdit(detailCustomer)">编辑</el-button>
<el-button v-if="auth.isDirector" type="danger" @click="detailVisible=false; handleDelete(detailCustomer)">删除</el-button>
<el-button @click="detailVisible=false">关闭</el-button>
</template>
</el-dialog>
@@ -418,7 +397,14 @@ async function handleImport() {
</template>
<style scoped>
.page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; }
.page-header h3 { margin: 0; }
.page-head { margin-bottom: 20px; }
.page-head-row { display: flex; justify-content: space-between; align-items: center; }
.page-title {
margin: 0;
font-family: 'ZCOOL XiaoWei', STSong, serif;
font-size: 22px; font-weight: 400;
color: var(--ink); letter-spacing: 0.06em;
}
.page-rule { width: 32px; height: 3px; background: var(--gold); margin-top: 8px; }
.header-btns { display: flex; gap: 8px; }
</style>
+285 -58
View File
@@ -27,68 +27,146 @@ onMounted(async () => {
}
})
function goWeeklyReport() {
router.push('/weekly-report')
function goWeeklyReport(managerId?: string) {
if (managerId) router.push({ path: '/weekly-report', query: { manager_id: managerId } })
else router.push('/weekly-report')
}
function rowState(p: any): 'full' | 'catching' | 'missing' {
if (p.has_reported_today && p.completed) return 'full'
if (p.has_reported_today && !p.completed) return 'catching'
return 'missing'
}
</script>
<template>
<div class="dashboard" v-loading="loading">
<h3>仪表盘</h3>
<p class="week-range">{{ stats.week_start }} ~ {{ stats.week_end }}</p>
<!-- Stats Cards -->
<el-row :gutter="16" style="margin-bottom: 24px">
<el-col :span="6">
<el-card shadow="hover" class="stat-card">
<div class="stat-value">{{ stats.week_visits }}</div>
<div class="stat-label">本周拜访次数</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card shadow="hover" class="stat-card">
<div class="stat-value">{{ stats.work_plans }}</div>
<div class="stat-label">工作计划条数</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card shadow="hover" class="stat-card">
<div class="stat-value">{{ stats.mini_business }}</div>
<div class="stat-label">商机跟单条数</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card shadow="hover" class="stat-card">
<div class="stat-value">{{ stats.key_visits }}</div>
<div class="stat-label">要客拜访条数</div>
</el-card>
</el-col>
</el-row>
<!-- Quick Actions -->
<div class="actions">
<el-button type="primary" @click="goWeeklyReport">📋 本周周报详情</el-button>
<el-button type="success" v-if="auth.isDirector" @click="router.push('/weekly-report')">📥 导出 Excel</el-button>
<el-button @click="router.push('/customers')">👥 客户管理</el-button>
<!-- Editorial Page Header -->
<div class="page-head">
<h2 class="page-title">仪表盘</h2>
<p class="page-sub">{{ stats.week_start }} {{ stats.week_end }}</p>
<div class="page-rule"></div>
</div>
<!-- Reporting Progress -->
<!-- Stats Cards -->
<div class="stat-grid">
<div class="stat-card">
<div class="stat-glyph stat-glyph--ink">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"></path>
<polyline points="9 22 9 12 15 12 15 22"></polyline>
</svg>
</div>
<div class="stat-body">
<span class="stat-num">{{ stats.week_visits }}</span>
<span class="stat-label">本周拜访</span>
</div>
</div>
<div class="stat-card">
<div class="stat-glyph stat-glyph--sage">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect>
<line x1="16" y1="2" x2="16" y2="6"></line>
<line x1="8" y1="2" x2="8" y2="6"></line>
<line x1="3" y1="10" x2="21" y2="10"></line>
</svg>
</div>
<div class="stat-body">
<span class="stat-num">{{ stats.work_plans }}</span>
<span class="stat-label">工作计划</span>
</div>
</div>
<div class="stat-card">
<div class="stat-glyph stat-glyph--gold">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<line x1="12" y1="1" x2="12" y2="23"></line>
<path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"></path>
</svg>
</div>
<div class="stat-body">
<span class="stat-num">{{ stats.mini_business }}</span>
<span class="stat-label">商机跟单</span>
</div>
</div>
<div class="stat-card">
<div class="stat-glyph stat-glyph--vermilion">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"></polygon>
</svg>
</div>
<div class="stat-body">
<span class="stat-num">{{ stats.key_visits }}</span>
<span class="stat-label">要客拜访</span>
</div>
</div>
</div>
<!-- Quick Actions -->
<div class="actions">
<el-button type="primary" @click="goWeeklyReport">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-right:4px">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path>
<polyline points="14 2 14 8 20 8"></polyline>
</svg>
本周周报详情
</el-button>
<el-button type="success" v-if="auth.isDirector" @click="router.push('/weekly-report')">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-right:4px">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
<polyline points="7 10 12 15 17 10"></polyline>
<line x1="12" y1="15" x2="12" y2="3"></line>
</svg>
导出 Excel
</el-button>
<el-button plain @click="router.push('/customers')">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-right:4px">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path>
<circle cx="9" cy="7" r="4"></circle>
<path d="M23 21v-2a4 4 0 0 0-3-3.87"></path>
<path d="M16 3.13a4 4 0 0 1 0 7.75"></path>
</svg>
客户管理
</el-button>
</div>
<!-- Reporting Progress -->
<el-card class="progress-card">
<template #header>填报进度</template>
<template #header>
<div class="card-header-title">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-right:6px; color: var(--gold)">
<line x1="18" y1="20" x2="18" y2="10"></line>
<line x1="12" y1="20" x2="12" y2="4"></line>
<line x1="6" y1="20" x2="6" y2="14"></line>
</svg>
填报进度
</div>
</template>
<div v-if="progress.length === 0" class="empty">暂无数据</div>
<div v-for="p in progress" :key="p.manager_id" class="progress-row">
<div
v-for="p in progress"
:key="p.manager_id"
class="progress-row"
:class="`progress-row--${rowState(p)}`"
>
<!-- Chinese Seal Watermark -->
<div class="seal-stamp" :class="`seal-stamp--${rowState(p)}`" aria-hidden="true">
<span class="seal-char">{{ { full: '满', catching: '追', missing: '未' }[rowState(p)] }}</span>
<span class="seal-char">{{ { full: '', catching: '', missing: '填' }[rowState(p)] }}</span>
</div>
<div class="progress-info">
<span class="progress-name">
{{ p.manager_name }}
<el-tag v-if="!p.has_reported_today" type="danger" size="small"> 今日未填</el-tag>
<el-tag v-else type="success" size="small"> 已填</el-tag>
<el-link type="primary" :underline="false" @click="goWeeklyReport(p.manager_id)">{{ p.manager_name }}</el-link>
<span class="progress-status" :class="`progress-status--${rowState(p)}`">
{{ { full: '本周已满', catching: '今日已填', missing: '今日未填' }[rowState(p)] }}
</span>
</span>
<span class="progress-count">{{ p.visit_count }} / {{ p.expected }} </span>
</div>
<el-progress
:percentage="Math.min(100, Math.round((p.visit_count / Math.max(p.expected, 1)) * 100))"
:color="p.completed ? '#67c23a' : '#f56c6c'"
:stroke-width="18"
:color="rowState(p) === 'full' ? '#4A6741' : rowState(p) === 'catching' ? '#C4934A' : '#B8472E'"
:stroke-width="12"
/>
</div>
</el-card>
@@ -96,16 +174,165 @@ function goWeeklyReport() {
</template>
<style scoped>
.dashboard h3 { margin: 0 0 4px; }
.week-range { color: #909399; font-size: 13px; margin-bottom: 20px; }
.stat-card { text-align: center; }
.stat-value { font-size: 36px; font-weight: 700; color: #1a73e8; }
.stat-label { font-size: 13px; color: #909399; margin-top: 4px; }
.actions { display: flex; gap: 12px; margin-bottom: 24px; }
.progress-card { margin-top: 20px; }
.progress-row { margin-bottom: 16px; }
.progress-info { display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px; }
.progress-name { display: flex; align-items: center; gap: 8px; font-weight: 500; }
.progress-count { font-size: 13px; color: #606266; }
.empty { text-align: center; color: #c0c4cc; padding: 24px 0; }
/* ═══ Page Header ═══ */
.page-head { margin-bottom: 24px; }
.page-title {
margin: 0;
font-family: 'ZCOOL XiaoWei', STSong, serif;
font-size: 22px; font-weight: 400;
color: var(--ink); letter-spacing: 0.06em;
}
.page-sub {
margin: 2px 0 0;
font-family: 'JetBrains Mono', 'SF Mono', monospace;
font-size: 11px; color: var(--gold); letter-spacing: 0.08em;
}
.page-rule { width: 32px; height: 3px; background: var(--gold); margin-top: 12px; }
/* ═══ Stat Grid ═══ */
.stat-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 16px;
margin-bottom: 24px;
}
.stat-card {
display: flex;
align-items: center;
gap: 16px;
background: var(--surface);
border: 1px solid var(--warm-border);
padding: 20px 22px;
transition: all var(--transition);
}
.stat-card:hover { box-shadow: var(--shadow-md); }
.stat-glyph {
width: 48px; height: 48px;
display: flex; align-items: center; justify-content: center;
flex-shrink: 0;
}
.stat-glyph--ink { background: #EEF2F2; color: var(--ink); }
.stat-glyph--sage { background: #EDF2EC; color: var(--sage); }
.stat-glyph--gold { background: #FBF6EE; color: var(--gold); }
.stat-glyph--vermilion { background: #FBF1EE; color: var(--vermilion); }
.stat-body { display: flex; flex-direction: column; }
.stat-num {
font-family: 'ZCOOL XiaoWei', STSong, serif;
font-size: 28px; color: var(--ink); line-height: 1.1; letter-spacing: 0.04em;
}
.stat-label {
font-family: 'Noto Serif SC', STSong, serif;
font-size: 12px; color: var(--warm-gray); letter-spacing: 0.04em;
}
/* ═══ Actions ═══ */
.actions { display: flex; gap: 10px; margin-bottom: 24px; }
/* ═══ Progress ═══ */
.progress-card { margin-top: 4px; }
.card-header-title {
display: flex; align-items: center;
font-family: 'ZCOOL XiaoWei', STSong, serif;
font-size: 15px; color: var(--ink); letter-spacing: 0.05em;
}
.progress-row {
position: relative;
margin-bottom: 24px;
padding: 16px 18px 14px;
overflow: hidden;
border-left: 4px solid transparent;
transition: border-color 0.3s;
}
.progress-row:last-child { margin-bottom: 0; }
/* Left edge accent — three states */
.progress-row--missing { border-left-color: var(--vermilion); background: rgba(184,71,46,0.02); }
.progress-row--catching { border-left-color: var(--gold); background: rgba(196,147,74,0.03); }
.progress-row--full { border-left-color: var(--sage); background: rgba(74,103,65,0.02); }
/* ═══ Chinese Seal Stamp Watermark ═══ */
.seal-stamp {
position: absolute;
right: 24px;
top: 50%;
transform: translateY(-52%) rotate(-10deg);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
border: 3px solid;
border-radius: 8px;
outline: 1.5px solid;
outline-offset: -6px;
pointer-events: none;
user-select: none;
opacity: 0.16;
transition: opacity 0.3s;
/* Single-char stamps (满/追/未) are square; double-char (未填) is slightly wider */
width: 56px;
height: 56px;
}
.progress-row:hover .seal-stamp {
opacity: 0.24;
}
/* Missing stamp — vermilion red ink */
.seal-stamp--missing {
border-color: var(--vermilion);
outline-color: var(--vermilion);
color: var(--vermilion);
}
/* Catching stamp — gold ink */
.seal-stamp--catching {
border-color: var(--gold);
outline-color: var(--gold);
color: var(--gold);
}
/* Full stamp — sage green ink */
.seal-stamp--full {
border-color: var(--sage);
outline-color: var(--sage);
color: var(--sage);
}
.seal-char {
font-family: 'ZCOOL XiaoWei', STSong, serif;
font-size: 20px;
font-weight: 400;
line-height: 1.1;
letter-spacing: 0.06em;
}
/* ═══ Progress Info ═══ */
.progress-info {
display: flex; justify-content: space-between;
align-items: center; margin-bottom: 10px;
position: relative; z-index: 1;
}
.progress-name { display: flex; align-items: center; gap: 10px; font-weight: 500; }
/* Inline status text (replaces el-tag) */
.progress-status {
font-family: 'Noto Serif SC', STSong, serif;
font-size: 12px;
letter-spacing: 0.05em;
}
.progress-status--missing { color: var(--vermilion); }
.progress-status--catching { color: var(--gold); }
.progress-status--full { color: var(--sage); }
.progress-count {
font-family: 'JetBrains Mono', 'SF Mono', monospace;
font-size: 13px; color: var(--warm-gray); letter-spacing: 0.03em;
position: relative; z-index: 1;
}
.empty { text-align: center; color: var(--c-text-muted); padding: 40px 0; font-family: 'Noto Serif SC', STSong, serif; }
</style>
+169 -67
View File
@@ -13,8 +13,10 @@ const miniBusiness = ref<any[]>([])
const dailyNotes = ref<any[]>([])
const keyVisits = ref<any[]>([])
const customers = ref<any[]>([])
const allUsers = ref<any[]>([])
const plannedVisitors = ref<string[]>([])
const dialogTimeRange = ref<any>(null)
// Dialog
const dialogVisible = ref(false)
const dialogMode = ref<'create' | 'edit'>('create')
const dialogType = ref('')
@@ -22,10 +24,23 @@ const form = ref<any>({})
const categories = ['行政事务', '合同整理', '发票处理', '内部会议', '培训学习', '其他']
const statusPick = ref<Record<string, string>>({})
const planStatuses = ['计划中', '已完成', '已取消']
const miniStatuses = ['跟进中', '已签约', '已流失']
const keyStatuses = ['未开始', '进行中', '已完成']
onMounted(async () => {
await Promise.all([loadAll(), loadCustomers()])
await Promise.all([loadAll(), loadCustomers(), loadUsers()])
})
async function loadUsers() {
try {
const res = await api.get('/users/')
allUsers.value = res.data || []
} catch (_) {}
}
function typeLabel(t: string) {
const labels: Record<string, string> = { visit: '拜访记录', note: '今日纪要', plan: '工作计划', mini: '小微商机', key: '要客拜访' }
return labels[t] || ''
@@ -57,23 +72,31 @@ async function loadAll() {
function openCreate(type: string) {
dialogMode.value = 'create'; dialogType.value = type
if (type === 'visit') form.value = { customer_id: '', visit_date: todayStr(), visit_method: '上门', time_range: '', communication_content: '', customer_demand: '' }
dialogTimeRange.value = null
if (type === 'visit') form.value = { customer_id: '', visit_date: todayStr(), visit_method: '上门', time_range: '', visitor_name: '', visitor_phone: '', communication_content: '', customer_demand: '' }
else if (type === 'note') form.value = { note_date: todayStr(), category: '其他', content: '', time_range: '' }
else if (type === 'plan') form.value = { customer_id: '', plan_content: '', plan_date: todayStr(), status: '计划中' }
else if (type === 'mini') form.value = { customer_id: '', product_type: '', amount: '', follow_up_detail: '', status: '跟进中', expected_revenue_date: '' }
else if (type === 'key') form.value = { customer_id: '', urgency_level: '一般', description: '', progress_status: '未开始', planned_date: '', planned_visitor: '', visit_target: '' }
else if (type === 'key') { form.value = { customer_id: '', urgency_level: '一般', description: '', progress_status: '未开始', planned_date: '', planned_visitor: '', visit_target: '' }; plannedVisitors.value = [] }
dialogVisible.value = true
}
function openEdit(type: string, item: any) {
dialogMode.value = 'edit'; dialogType.value = type
form.value = { ...item }
dialogTimeRange.value = null
if ((type === 'visit' || type === 'note') && item.time_range && item.time_range.includes('-')) {
const parts = item.time_range.split('-')
dialogTimeRange.value = [parts[0], parts[1]]
}
if (type === 'key') {
plannedVisitors.value = item.planned_visitor ? item.planned_visitor.split('、') : []
}
dialogVisible.value = true
}
async function handleSave() {
const t = dialogType.value
const d = form.value
const t = dialogType.value; const d = form.value
try {
if (dialogMode.value === 'create') {
switch (t) {
@@ -113,6 +136,36 @@ async function handleDelete(type: string, id: string) {
} catch (e: any) { if (e !== 'cancel') ElMessage.error('删除失败') }
}
function onVisitorsChange(val: string[]) {
form.value.planned_visitor = val.join('、')
}
function onDialogTimeChange(val: [string, string] | null) {
form.value.time_range = val ? val.join('-') : ''
}
// Quick status change
async function quickStatusChange(type: string, row: any, newStatus: string) {
try {
await ElMessageBox.confirm(`确定将状态改为「${newStatus}」?`, '确认', { type: 'warning', confirmButtonText: '确定', cancelButtonText: '取消' })
const payload: any = {}
if (type === 'plan') payload.status = newStatus
else if (type === 'mini') payload.status = newStatus
else if (type === 'key') payload.progress_status = newStatus
let url = ''
if (type === 'plan') url = `/work-plans/${row.id}`
else if (type === 'mini') url = `/mini-business/${row.id}`
else if (type === 'key') url = `/key-visits/${row.id}`
await api.put(url, payload)
ElMessage.success('状态已更新')
await loadAll()
} catch (e: any) {
if (e !== 'cancel') ElMessage.error('更新失败: ' + (e.response?.data?.detail || e.message))
// Reset statusPick back to original on cancel/error
delete statusPick.value[row.id]
}
}
const visitsByDate = computed(() => {
const g: Record<string, any[]> = {}
for (const v of visits.value) { const d = v.visit_date; if (!g[d]) g[d] = []; g[d].push(v) }
@@ -127,24 +180,32 @@ const notesByDate = computed(() => {
<template>
<div class="workspace" v-loading="loading">
<h3>我的工作数据</h3>
<div class="page-head">
<h2 class="page-title">我的工作数据</h2>
<div class="page-rule"></div>
</div>
<el-card>
<el-tabs v-model="activeTab">
<!-- 拜访记录 -->
<!-- 拜访记录 -->
<el-tab-pane label="拜访记录" name="visits">
<div style="margin-bottom:12px"><el-button type="primary" size="small" @click="openCreate('visit')">+ 新建拜访</el-button></div>
<div v-for="[date, items] in visitsByDate" :key="date" class="date-group">
<h4>📅 {{ date }}</h4>
<h4 class="date-title">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-right:4px; color: var(--gold)">
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect><line x1="16" y1="2" x2="16" y2="6"></line><line x1="8" y1="2" x2="8" y2="6"></line><line x1="3" y1="10" x2="21" y2="10"></line>
</svg>
{{ date }}
</h4>
<el-table :data="items" stripe size="small">
<el-table-column prop="customer_name" label="客户" width="130">
<template #default="{ row }"><el-link type="primary" :underline="false">{{ row.customer_name }}</el-link></template>
<template #default="{ row }"><el-link type="primary" :underline="false" @click="openEdit('visit', row)">{{ row.customer_name }}</el-link></template>
</el-table-column>
<el-table-column prop="visit_method" label="方式" width="60" />
<el-table-column prop="time_range" label="时间" width="100" />
<el-table-column prop="communication_content" label="沟通内容" min-width="200" show-overflow-tooltip />
<el-table-column label="操作" width="120">
<el-table-column label="操作" width="70">
<template #default="{ row }">
<el-button text size="small" @click="openEdit('visit', row)">编辑</el-button>
<el-button text size="small" type="danger" @click="handleDelete('visit', row.id)">删除</el-button>
</template>
</el-table-column>
@@ -153,18 +214,24 @@ const notesByDate = computed(() => {
<div v-if="!visits.length" class="empty">暂无数据</div>
</el-tab-pane>
<!-- 今日纪要 -->
<!-- 今日纪要 -->
<el-tab-pane label="今日纪要" name="daily_notes">
<div style="margin-bottom:12px"><el-button type="primary" size="small" @click="openCreate('note')">+ 新建纪要</el-button></div>
<div v-for="[date, items] in notesByDate" :key="date" class="date-group">
<h4>📅 {{ date }}</h4>
<h4 class="date-title">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-right:4px; color: var(--gold)">
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect><line x1="16" y1="2" x2="16" y2="6"></line><line x1="8" y1="2" x2="8" y2="6"></line><line x1="3" y1="10" x2="21" y2="10"></line>
</svg>
{{ date }}
</h4>
<el-table :data="items" stripe size="small">
<el-table-column prop="category" label="分类" width="100"><template #default="{ row }"><el-tag size="small">{{ row.category }}</el-tag></template></el-table-column>
<el-table-column prop="content" label="工作内容" min-width="250" show-overflow-tooltip />
<el-table-column prop="content" label="工作内容" min-width="250" show-overflow-tooltip>
<template #default="{ row }"><el-link type="primary" :underline="false" @click="openEdit('note', row)">{{ row.content }}</el-link></template>
</el-table-column>
<el-table-column prop="time_range" label="时间" width="100" />
<el-table-column label="操作" width="120">
<el-table-column label="操作" width="70">
<template #default="{ row }">
<el-button text size="small" @click="openEdit('note', row)">编辑</el-button>
<el-button text size="small" type="danger" @click="handleDelete('note', row.id)">删除</el-button>
</template>
</el-table-column>
@@ -173,19 +240,31 @@ const notesByDate = computed(() => {
<div v-if="!dailyNotes.length" class="empty">暂无数据</div>
</el-tab-pane>
<!-- 工作计划 -->
<!-- 工作计划 -->
<el-tab-pane label="工作计划" name="work_plans">
<div style="margin-bottom:12px"><el-button type="primary" size="small" @click="openCreate('plan')">+ 新建计划</el-button></div>
<el-table :data="workPlans" stripe size="small">
<el-table-column prop="customer_name" label="客户" width="130">
<template #default="{ row }"><el-link type="primary" :underline="false">{{ row.customer_name }}</el-link></template>
<template #default="{ row }"><el-link type="primary" :underline="false" @click="openEdit('plan', row)">{{ row.customer_name }}</el-link></template>
</el-table-column>
<el-table-column prop="plan_content" label="工作计划" min-width="250" show-overflow-tooltip />
<el-table-column prop="plan_content" label="工作计划" min-width="200" show-overflow-tooltip />
<el-table-column prop="plan_date" label="计划时间" width="110" />
<el-table-column prop="status" label="状态" width="90"><template #default="{ row }"><el-tag size="small">{{ row.status }}</el-tag></template></el-table-column>
<el-table-column label="操作" width="120">
<el-table-column label="状态" width="120">
<template #default="{ row }">
<el-select
v-model="statusPick[row.id]"
size="small"
style="width:100%"
:placeholder="row.status"
@change="(v: string) => quickStatusChange('plan', row, v)"
@visible-change="(v: boolean) => { if (v) statusPick[row.id] = row.status }"
>
<el-option v-for="s in planStatuses" :key="s" :label="s" :value="s" />
</el-select>
</template>
</el-table-column>
<el-table-column label="操作" width="70">
<template #default="{ row }">
<el-button text size="small" @click="openEdit('plan', row)">编辑</el-button>
<el-button text size="small" type="danger" @click="handleDelete('plan', row.id)">删除</el-button>
</template>
</el-table-column>
@@ -193,21 +272,33 @@ const notesByDate = computed(() => {
<div v-if="!workPlans.length" class="empty">暂无数据</div>
</el-tab-pane>
<!-- 小微商机 -->
<!-- ═══ 小微商机 ═══ -->
<el-tab-pane label="小微商机" name="mini_biz">
<div style="margin-bottom:12px"><el-button type="primary" size="small" @click="openCreate('mini')">+ 新建商机</el-button></div>
<el-table :data="miniBusiness" stripe size="small">
<el-table-column prop="customer_name" label="客户" width="130">
<template #default="{ row }"><el-link type="primary" :underline="false">{{ row.customer_name }}</el-link></template>
<template #default="{ row }"><el-link type="primary" :underline="false" @click="openEdit('mini', row)">{{ row.customer_name }}</el-link></template>
</el-table-column>
<el-table-column prop="product_type" label="产品类型" width="120" />
<el-table-column prop="amount" label="金额" width="90" />
<el-table-column prop="follow_up_detail" label="跟进内容" min-width="200" show-overflow-tooltip />
<el-table-column prop="status" label="状态" width="90"><template #default="{ row }"><el-tag size="small">{{ row.status }}</el-tag></template></el-table-column>
<el-table-column prop="expected_revenue_date" label="预计列收" width="110" />
<el-table-column label="操作" width="120">
<el-table-column label="状态" width="120">
<template #default="{ row }">
<el-select
v-model="statusPick[row.id]"
size="small"
style="width:100%"
:placeholder="row.status"
@change="(v: string) => quickStatusChange('mini', row, v)"
@visible-change="(v: boolean) => { if (v) statusPick[row.id] = row.status }"
>
<el-option v-for="s in miniStatuses" :key="s" :label="s" :value="s" />
</el-select>
</template>
</el-table-column>
<el-table-column prop="expected_revenue_date" label="预计列收" width="110" />
<el-table-column label="操作" width="70">
<template #default="{ row }">
<el-button text size="small" @click="openEdit('mini', row)">编辑</el-button>
<el-button text size="small" type="danger" @click="handleDelete('mini', row.id)">删除</el-button>
</template>
</el-table-column>
@@ -215,22 +306,34 @@ const notesByDate = computed(() => {
<div v-if="!miniBusiness.length" class="empty">暂无数据</div>
</el-tab-pane>
<!-- 要客拜访 -->
<!-- ═══ 要客拜访 ═══ -->
<el-tab-pane label="要客拜访" name="key_visits">
<div style="margin-bottom:12px"><el-button type="primary" size="small" @click="openCreate('key')">+ 新建要客拜访</el-button></div>
<el-table :data="keyVisits" stripe size="small">
<el-table-column prop="customer_name" label="客户" width="130">
<template #default="{ row }"><el-link type="primary" :underline="false">{{ row.customer_name }}</el-link></template>
<template #default="{ row }"><el-link type="primary" :underline="false" @click="openEdit('key', row)">{{ row.customer_name }}</el-link></template>
</el-table-column>
<el-table-column prop="urgency_level" label="重要度" width="80"><template #default="{ row }"><el-tag size="small" :type="row.urgency_level==='紧急'?'danger':row.urgency_level==='重要'?'warning':''">{{ row.urgency_level }}</el-tag></template></el-table-column>
<el-table-column prop="description" label="描述" min-width="200" show-overflow-tooltip />
<el-table-column prop="progress_status" label="进展" width="80" />
<el-table-column label="进展" width="120">
<template #default="{ row }">
<el-select
v-model="statusPick[row.id]"
size="small"
style="width:100%"
:placeholder="row.progress_status"
@change="(v: string) => quickStatusChange('key', row, v)"
@visible-change="(v: boolean) => { if (v) statusPick[row.id] = row.progress_status }"
>
<el-option v-for="s in keyStatuses" :key="s" :label="s" :value="s" />
</el-select>
</template>
</el-table-column>
<el-table-column prop="planned_date" label="计划时间" width="110" />
<el-table-column prop="planned_visitor" label="拜访人" width="80" />
<el-table-column prop="visit_target" label="拜访对象" width="100" />
<el-table-column label="操作" width="120">
<el-table-column label="操作" width="70">
<template #default="{ row }">
<el-button text size="small" @click="openEdit('key', row)">编辑</el-button>
<el-button text size="small" type="danger" @click="handleDelete('key', row.id)">删除</el-button>
</template>
</el-table-column>
@@ -241,65 +344,62 @@ const notesByDate = computed(() => {
</el-card>
<!-- Create/Edit Dialog -->
<el-dialog v-model="dialogVisible" :title="(dialogMode === 'create' ? '新建' : '编辑') + typeLabel(dialogType)" width="520px">
<el-dialog v-model="dialogVisible" :title="(dialogMode === 'create' ? '新建' : '编辑') + ' ' + typeLabel(dialogType)" width="520px">
<el-form label-position="top" v-if="form">
<!-- Customer selector for types that need it -->
<el-form-item v-if="['visit','plan','mini','key'].includes(dialogType)" label="客户单位">
<el-select v-model="form.customer_id" filterable remote :remote-method="(q: string) => loadCustomers(q)" placeholder="搜索选择客户" style="width:100%">
<el-option v-for="c in customers" :key="c.id" :label="c.name" :value="c.id" />
</el-select>
</el-form-item>
<!-- Date -->
<el-form-item v-if="dialogType === 'visit'" label="日期">
<el-date-picker v-model="form.visit_date" type="date" style="width:100%" />
</el-form-item>
<el-form-item v-if="dialogType === 'note'" label="日期">
<el-date-picker v-model="form.note_date" type="date" style="width:100%" />
</el-form-item>
<el-form-item v-if="dialogType === 'plan'" label="计划拜访时间">
<el-date-picker v-model="form.plan_date" type="date" style="width:100%" />
</el-form-item>
<!-- Visit specific -->
<el-form-item v-if="dialogType === 'visit'" label="日期"><el-date-picker v-model="form.visit_date" type="date" style="width:100%" /></el-form-item>
<el-form-item v-if="dialogType === 'note'" label="日期"><el-date-picker v-model="form.note_date" type="date" style="width:100%" /></el-form-item>
<el-form-item v-if="dialogType === 'plan'" label="计划拜访时间"><el-date-picker v-model="form.plan_date" type="date" style="width:100%" /></el-form-item>
<template v-if="dialogType === 'visit'">
<el-form-item label="拜访方式">
<el-radio-group v-model="form.visit_method">
<el-radio-button value="上门">上门</el-radio-button><el-radio-button value="电话">电话</el-radio-button><el-radio-button value="微信">微信</el-radio-button><el-radio-button value="出差">出差</el-radio-button>
</el-radio-group>
<div class="method-grid">
<el-button v-for="m in ['上门','电话','微信','出差']" :key="m" size="small" :type="form.visit_method===m?'primary':''" @click="form.visit_method=m">{{ m }}</el-button>
</div>
</el-form-item>
<el-form-item label="时间范围"><el-input v-model="form.time_range" /></el-form-item>
<el-form-item label="时间范围">
<el-time-picker v-model="dialogTimeRange" is-range format="HH:mm" value-format="HH:mm" range-separator="" start-placeholder="开始" end-placeholder="结束" style="width:100%" @change="onDialogTimeChange" />
</el-form-item>
<el-form-item label="拜访人姓名"><el-input v-model="form.visitor_name" placeholder="实际拜访人姓名" /></el-form-item>
<el-form-item label="拜访人电话"><el-input v-model="form.visitor_phone" placeholder="联系电话可选" /></el-form-item>
<el-form-item label="沟通内容"><el-input v-model="form.communication_content" type="textarea" :rows="3" /></el-form-item>
<el-form-item label="客户需求"><el-input v-model="form.customer_demand" type="textarea" :rows="2" /></el-form-item>
</template>
<!-- Note specific -->
<template v-if="dialogType === 'note'">
<el-form-item label="分类">
<div style="display:flex;flex-wrap:wrap;gap:6px">
<el-button v-for="c in categories" :key="c" :type="form.category===c?'primary':''" size="small" @click="form.category=c">{{ c }}</el-button>
</div>
<el-form-item label="分类"><div style="display:flex;flex-wrap:wrap;gap:6px"><el-button v-for="c in categories" :key="c" :type="form.category===c?'primary':''" size="small" @click="form.category=c">{{ c }}</el-button></div></el-form-item>
<el-form-item label="时间范围">
<el-time-picker v-model="dialogTimeRange" is-range format="HH:mm" value-format="HH:mm" range-separator="" start-placeholder="开始" end-placeholder="结束" style="width:100%" @change="onDialogTimeChange" />
</el-form-item>
<el-form-item label="时间范围"><el-input v-model="form.time_range" /></el-form-item>
<el-form-item label="工作内容"><el-input v-model="form.content" type="textarea" :rows="4" /></el-form-item>
</template>
<!-- Plan specific -->
<template v-if="dialogType === 'plan'">
<el-form-item label="工作计划"><el-input v-model="form.plan_content" type="textarea" :rows="4" /></el-form-item>
<el-form-item label="状态"><el-select v-model="form.status"><el-option label="计划中" value="计划中" /><el-option label="已完成" value="已完成" /><el-option label="已取消" value="已取消" /></el-select></el-form-item>
</template>
<!-- Mini biz specific -->
<template v-if="dialogType === 'mini'">
<el-form-item label="产品类型"><el-input v-model="form.product_type" /></el-form-item>
<el-form-item label="金额"><el-input v-model="form.amount" /></el-form-item>
<el-form-item label="跟进内容"><el-input v-model="form.follow_up_detail" type="textarea" :rows="4" /></el-form-item>
<el-form-item label="状态"><el-select v-model="form.status"><el-option label="跟进中" value="跟进中" /><el-option label="已签约" value="已签约" /><el-option label="已流失" value="已流失" /></el-select></el-form-item>
<el-form-item label="预计列收时间"><el-input v-model="form.expected_revenue_date" /></el-form-item>
<el-form-item label="预计列收时间"><el-date-picker v-model="form.expected_revenue_date" type="month" placeholder="选择月份" style="width:100%" value-format="YYYY-MM" /></el-form-item>
</template>
<!-- Key visit specific -->
<template v-if="dialogType === 'key'">
<el-form-item label="紧急重要度"><el-radio-group v-model="form.urgency_level"><el-radio-button value="一般">一般</el-radio-button><el-radio-button value="重要">重要</el-radio-button><el-radio-button value="紧急">紧急</el-radio-button></el-radio-group></el-form-item>
<el-form-item label="紧急重要度">
<div class="method-grid">
<el-button v-for="u in ['一般','重要','紧急']" :key="u" size="small" :type="form.urgency_level===u ? (u==='紧急'?'danger':u==='重要'?'warning':'') : ''" @click="form.urgency_level=u">{{ u }}</el-button>
</div>
</el-form-item>
<el-form-item label="内容描述"><el-input v-model="form.description" type="textarea" :rows="4" /></el-form-item>
<el-form-item label="进展状态"><el-select v-model="form.progress_status"><el-option label="未开始" value="未开始" /><el-option label="进行中" value="进行中" /><el-option label="已完成" value="已完成" /></el-select></el-form-item>
<el-form-item label="计划拜访时间"><el-input v-model="form.planned_date" /></el-form-item>
<el-form-item label="计划拜访人"><el-input v-model="form.planned_visitor" /></el-form-item>
<el-form-item label="计划拜访时间"><el-date-picker v-model="form.planned_date" type="date" placeholder="选择日期" style="width:100%" value-format="YYYY-MM-DD" /></el-form-item>
<el-form-item label="计划拜访人可多选">
<el-select v-model="plannedVisitors" multiple filterable allow-create default-first-option placeholder="选择或输入姓名可多选" style="width:100%" @change="onVisitorsChange">
<el-option v-for="u in allUsers" :key="u.id" :label="u.name + (u.role==='director'?' (支局长)':u.role==='leader'?' (分管领导)':'')" :value="u.name" />
</el-select>
</el-form-item>
<el-form-item label="拜访对象"><el-input v-model="form.visit_target" /></el-form-item>
</template>
</el-form>
@@ -312,8 +412,10 @@ const notesByDate = computed(() => {
</template>
<style scoped>
.workspace h3 { margin: 0 0 16px; }
.page-head { margin-bottom: 20px; }
.page-title { margin: 0; font-family: 'ZCOOL XiaoWei', STSong, serif; font-size: 22px; font-weight: 400; color: var(--ink); letter-spacing: 0.06em; }
.page-rule { width: 32px; height: 3px; background: var(--gold); margin-top: 12px; }
.date-group { margin-bottom: 20px; }
.date-group h4 { margin: 12px 0 8px; font-size: 14px; color: #303133; }
.empty { text-align: center; color: #c0c4cc; padding: 40px 0; }
.date-title { display: flex; align-items: center; margin: 12px 0 8px; font-family: 'ZCOOL XiaoWei', STSong, serif; font-size: 14px; color: var(--ink); letter-spacing: 0.04em; }
.empty { text-align: center; color: var(--c-text-muted); padding: 40px 0; font-family: 'Noto Serif SC', STSong, serif; }
</style>
+84 -52
View File
@@ -11,11 +11,9 @@ const remindLoading = ref(false)
const announceLoading = ref(false)
const dailyCheckLoading = ref(false)
// Import
const importDialogVisible = ref(false)
const importLoading = ref(false)
const importFile = ref<File | null>(null)
const importPreview = ref<any>(null)
const importLoading = ref(false)
const importResult = ref<any>(null)
onMounted(async () => {
@@ -29,32 +27,21 @@ async function handleRemind() {
if (!selectedUserIds.value.length) { ElMessage.warning('请选择要提醒的人员'); return }
remindLoading.value = true
try {
const res = await api.post('/wecom/remind', {
user_ids: selectedUserIds.value,
message: remindMessage.value || undefined,
})
const res = await api.post('/wecom/remind', { user_ids: selectedUserIds.value, message: remindMessage.value || undefined })
ElMessage.success(`已发送提醒给 ${res.data.sent_to}`)
} catch (e: any) {
ElMessage.error('发送失败')
} finally {
remindLoading.value = false
}
} catch (e: any) { ElMessage.error('发送失败') }
finally { remindLoading.value = false }
}
async function handleAnnouncement() {
if (!announcementContent.value) { ElMessage.warning('请输入公告内容'); return }
announceLoading.value = true
try {
const res = await api.post('/wecom/announcement', {
content: announcementContent.value,
})
const res = await api.post('/wecom/announcement', { content: announcementContent.value })
ElMessage.success(`公告已推送给 ${res.data.sent_to}`)
announcementContent.value = ''
} catch (e: any) {
ElMessage.error('推送失败')
} finally {
announceLoading.value = false
}
} catch (e: any) { ElMessage.error('推送失败') }
finally { announceLoading.value = false }
}
async function handleDailyCheck() {
@@ -62,18 +49,22 @@ async function handleDailyCheck() {
try {
const res = await api.post('/wecom/trigger-daily-check')
ElMessage.success(`已执行:${res.data.reported}/${res.data.total_managers} 人已填报`)
} catch (e: any) {
ElMessage.error('执行失败')
} finally {
dailyCheckLoading.value = false
}
} catch (e: any) { ElMessage.error('执行失败') }
finally { dailyCheckLoading.value = false }
}
function handleImportFile(e: Event) {
const target = e.target as HTMLInputElement
if (target.files?.[0]) {
importFile.value = target.files[0]
}
if (target.files?.[0]) importFile.value = target.files[0]
}
async function downloadWeeklyTemplate() {
try {
const res = await api.get('/import/template', { responseType: 'blob' })
const url = URL.createObjectURL(res.data)
const a = document.createElement('a'); a.href = url; a.download = 'weekly_report_template.xlsx'; a.click()
URL.revokeObjectURL(url)
} catch (e: any) { ElMessage.error('下载失败') }
}
async function handleImportPreview() {
@@ -82,26 +73,31 @@ async function handleImportPreview() {
const formData = new FormData()
formData.append('file', importFile.value)
try {
const res = await api.post('/import/weekly-report', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
})
const res = await api.post('/import/weekly-report', formData, { headers: { 'Content-Type': 'multipart/form-data' } })
importPreview.value = res.data.preview
importResult.value = res.data
} catch (e: any) {
ElMessage.error('解析失败')
} finally {
importLoading.value = false
}
} catch (e: any) { ElMessage.error('解析失败') }
finally { importLoading.value = false }
}
</script>
<template>
<div class="settings-page">
<h3>系统设置</h3>
<div class="page-head">
<h2 class="page-title">系统设置</h2>
<div class="page-rule"></div>
</div>
<!-- Manual Remind -->
<el-card class="setting-card">
<template #header>📢 手动催办</template>
<template #header>
<div class="card-header-title">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-right:6px; color: var(--gold)">
<path d="M22 17H2a3 3 0 0 0 3-3V9a7 7 0 0 1 14 0v5a3 3 0 0 0 3 3zm-8.27 4a2 2 0 0 1-3.46 0"></path>
</svg>
手动催办
</div>
</template>
<el-form>
<el-form-item label="选择人员">
<el-select v-model="selectedUserIds" multiple placeholder="选择客户经理" style="width:100%">
@@ -117,7 +113,15 @@ async function handleImportPreview() {
<!-- Announcement -->
<el-card class="setting-card">
<template #header>📣 全员公告</template>
<template #header>
<div class="card-header-title">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-right:6px; color: var(--gold)">
<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"></path>
<path d="M13.73 21a2 2 0 0 1-3.46 0"></path>
</svg>
全员公告
</div>
</template>
<el-form>
<el-form-item label="公告内容">
<el-input v-model="announcementContent" type="textarea" :rows="3" placeholder="输入公告内容..." />
@@ -128,32 +132,47 @@ async function handleImportPreview() {
<!-- Daily Check -->
<el-card class="setting-card">
<template #header> 填报检查</template>
<p>手动触发今日填报检查通常每日 18:00 自动执行</p>
<template #header>
<div class="card-header-title">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-right:6px; color: var(--gold)">
<circle cx="12" cy="12" r="10"></circle>
<polyline points="12 6 12 12 16 14"></polyline>
</svg>
填报检查
</div>
</template>
<p style="color: var(--warm-gray); font-family: 'Noto Serif SC', STSong, serif;">手动触发今日填报检查通常每日 18:00 自动执行</p>
<el-button :loading="dailyCheckLoading" @click="handleDailyCheck">立即检查</el-button>
</el-card>
<!-- Import -->
<el-card class="setting-card">
<template #header>📥 Excel 数据导入</template>
<template #header>
<div class="card-header-title">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-right:6px; color: var(--gold)">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
<polyline points="7 10 12 15 17 10"></polyline>
<line x1="12" y1="15" x2="12" y2="3"></line>
</svg>
Excel 数据导入
</div>
</template>
<el-form>
<el-form-item label="上传旧周报 Excel">
<input type="file" accept=".xlsx,.xls" @change="handleImportFile" />
<div style="display:flex;gap:8px;align-items:center">
<input type="file" accept=".xlsx,.xls" @change="handleImportFile" />
<el-button size="small" @click="downloadWeeklyTemplate">📋 下载模板</el-button>
</div>
</el-form-item>
<el-button type="success" :loading="importLoading" @click="handleImportPreview" :disabled="!importFile">
预览并导入
</el-button>
<el-button type="success" :loading="importLoading" @click="handleImportPreview" :disabled="!importFile">预览并导入</el-button>
</el-form>
<!-- Preview Results -->
<div v-if="importPreview" style="margin-top:16px">
<h4>文件预览</h4>
<h4 style="font-family: 'ZCOOL XiaoWei', STSong, serif; color: var(--ink)">文件预览</h4>
<el-table :data="Object.entries(importPreview)" stripe>
<el-table-column prop="0" label="Sheet" />
<el-table-column prop="1.row_count" label="数据行数" />
<el-table-column label="表头">
<template #default="{ row }">
{{ (row[1] as any).headers.join(', ') }}
</template>
<template #default="{ row }">{{ (row[1] as any).headers.join(', ') }}</template>
</el-table-column>
</el-table>
</div>
@@ -167,6 +186,19 @@ async function handleImportPreview() {
</template>
<style scoped>
.settings-page h3 { margin: 0 0 20px; }
.page-head { margin-bottom: 24px; }
.page-title {
margin: 0;
font-family: 'ZCOOL XiaoWei', STSong, serif;
font-size: 22px; font-weight: 400;
color: var(--ink); letter-spacing: 0.06em;
}
.page-rule { width: 32px; height: 3px; background: var(--gold); margin-top: 12px; }
.setting-card { margin-bottom: 16px; }
.card-header-title {
display: flex; align-items: center;
font-family: 'ZCOOL XiaoWei', STSong, serif;
font-size: 15px; color: var(--ink); letter-spacing: 0.05em;
}
</style>
+19 -35
View File
@@ -17,14 +17,10 @@ const roleOptions = [
]
const roleTagType: Record<string, string> = {
manager: '',
director: 'warning',
leader: 'info',
manager: '', director: 'warning', leader: 'info',
}
const roleLabel: Record<string, string> = {
manager: '客户经理',
director: '支局长',
leader: '分管领导',
manager: '客户经理', director: '支局长', leader: '分管领导',
}
async function loadUsers() {
@@ -32,11 +28,8 @@ async function loadUsers() {
try {
const res = await api.get('/users/')
users.value = res.data
} catch (e: any) {
ElMessage.error('加载用户列表失败')
} finally {
loading.value = false
}
} catch (e: any) { ElMessage.error('加载用户列表失败') }
finally { loading.value = false }
}
onMounted(loadUsers)
@@ -51,34 +44,29 @@ function openEdit(user: any) {
async function handleSave() {
if (!editUser.value) return
try {
await api.put(`/users/${editUser.value.id}/role`, {
role: editRole.value,
department: editDepartment.value,
})
await api.put(`/users/${editUser.value.id}/role`, { role: editRole.value, department: editDepartment.value })
ElMessage.success('角色已更新')
editDialogVisible.value = false
await loadUsers()
} catch (e: any) {
ElMessage.error('更新失败: ' + (e.response?.data?.detail || e.message))
}
} catch (e: any) { ElMessage.error('更新失败: ' + (e.response?.data?.detail || e.message)) }
}
</script>
<template>
<div class="user-manage" v-loading="loading">
<div class="page-header">
<h3>用户管理</h3>
<div class="page-head">
<h2 class="page-title">用户管理</h2>
<div class="page-rule"></div>
</div>
<el-card>
<el-table :data="users" stripe>
<el-table-column type="index" label="序号" width="60" />
<el-table-column prop="name" label="姓名" width="120" />
<el-table-column label="角色" width="120">
<template #default="{ row }">
<el-tag :type="roleTagType[row.role]">{{ roleLabel[row.role] || row.role }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="department" label="部门" min-width="150" />
<el-table-column prop="casdoor_id" label="Casdoor ID" min-width="200" />
<el-table-column prop="wecom_userid" label="企微 UserID" width="140">
<template #default="{ row }">{{ row.wecom_userid || '-' }}</template>
</el-table-column>
@@ -90,21 +78,16 @@ async function handleSave() {
</el-table>
</el-card>
<!-- Edit Dialog -->
<el-dialog v-model="editDialogVisible" title="修改用户角色" width="420px">
<template v-if="editUser">
<el-form label-position="top">
<el-form-item label="用户">
<el-input :value="editUser.name" disabled />
</el-form-item>
<el-form-item label="用户"><el-input :value="editUser.name" disabled /></el-form-item>
<el-form-item label="角色">
<el-select v-model="editRole" style="width:100%">
<el-option v-for="opt in roleOptions" :key="opt.value" :label="opt.label" :value="opt.value" />
</el-select>
</el-form-item>
<el-form-item label="部门(可选)">
<el-input v-model="editDepartment" placeholder="如:XX支局" />
</el-form-item>
<el-form-item label="部门(可选)"><el-input v-model="editDepartment" placeholder="如:XX支局" /></el-form-item>
</el-form>
</template>
<template #footer>
@@ -116,11 +99,12 @@ async function handleSave() {
</template>
<style scoped>
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
.page-head { margin-bottom: 20px; }
.page-title {
margin: 0;
font-family: 'ZCOOL XiaoWei', STSong, serif;
font-size: 22px; font-weight: 400;
color: var(--ink); letter-spacing: 0.06em;
}
.page-header h3 { margin: 0; }
.page-rule { width: 32px; height: 3px; background: var(--gold); margin-top: 12px; }
</style>
+71 -36
View File
@@ -1,5 +1,6 @@
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { useRoute } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { dashboardApi } from '@/api/dashboard'
import { uploadApi } from '@/api/upload'
@@ -7,6 +8,7 @@ import { ElMessage } from 'element-plus'
import api from '@/api/index'
const auth = useAuthStore()
const route = useRoute()
const loading = ref(false)
const activeTab = ref('visits')
const filterManagerId = ref('')
@@ -27,6 +29,7 @@ const photoDialogVisible = ref(false)
const currentPhotoUrl = ref('')
onMounted(async () => {
if (route.query.manager_id) filterManagerId.value = route.query.manager_id as string
await loadReport()
try {
const res = await api.get('/users/', { params: { role: 'manager' } })
@@ -42,7 +45,6 @@ async function loadReport() {
if (filterCustomerId.value) params.customer_id = filterCustomerId.value
const res = await dashboardApi.getWeeklyReport(params)
report.value = res.data
// Load photo URLs
for (const v of report.value.visits) {
if (v.photos?.length) {
for (const key of v.photos) {
@@ -67,14 +69,10 @@ async function handleExport() {
const res = await api.get('/export/weekly-report', { responseType: 'blob' })
const url = URL.createObjectURL(res.data)
const a = document.createElement('a')
a.href = url
a.download = 'weekly_report.xlsx'
a.click()
a.href = url; a.download = 'weekly_report.xlsx'; a.click()
URL.revokeObjectURL(url)
ElMessage.success('导出成功')
} catch (e: any) {
ElMessage.error('导出失败')
}
} catch (e: any) { ElMessage.error('导出失败') }
}
function viewPhoto(url: string) {
@@ -82,7 +80,6 @@ function viewPhoto(url: string) {
photoDialogVisible.value = true
}
// Group visits by date for timeline
const visitsByDate = computed(() => {
const grouped: Record<string, any[]> = {}
for (const v of report.value.visits) {
@@ -106,14 +103,25 @@ const notesByDate = computed(() => {
<template>
<div class="weekly-report" v-loading="loading">
<div class="page-header">
<div>
<h3>周报详情</h3>
<p>{{ report.week_start }} ~ {{ report.week_end }}</p>
</div>
<div>
<el-button v-if="auth.isDirector" type="success" @click="handleExport">📥 导出 Excel</el-button>
<!-- Page Header -->
<div class="page-head">
<div class="page-head-row">
<div>
<h2 class="page-title">周报详情</h2>
<p class="page-sub">{{ report.week_start }} {{ report.week_end }}</p>
</div>
<div>
<el-button v-if="auth.isDirector" type="success" @click="handleExport">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-right:4px">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
<polyline points="7 10 12 15 17 10"></polyline>
<line x1="12" y1="15" x2="12" y2="3"></line>
</svg>
导出 Excel
</el-button>
</div>
</div>
<div class="page-rule"></div>
</div>
<!-- Filters -->
@@ -130,13 +138,21 @@ const notesByDate = computed(() => {
</el-row>
</el-card>
<!-- 4 Tabs -->
<!-- Tabs -->
<el-card>
<el-tabs v-model="activeTab">
<el-tab-pane label="每日拜访记录" name="visits">
<div v-if="report.visits.length === 0" class="empty">暂无数据</div>
<div v-for="[date, items] in visitsByDate" :key="date" class="date-group">
<h4 class="date-title">📅 {{ date }}</h4>
<h4 class="date-title">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-right:4px; color: var(--gold)">
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect>
<line x1="16" y1="2" x2="16" y2="6"></line>
<line x1="8" y1="2" x2="8" y2="6"></line>
<line x1="3" y1="10" x2="21" y2="10"></line>
</svg>
{{ date }}
</h4>
<el-table :data="items" stripe style="width:100%">
<el-table-column prop="customer_name" label="客户" width="120" />
<el-table-column prop="visit_method" label="方式" width="60" />
@@ -147,12 +163,9 @@ const notesByDate = computed(() => {
<el-table-column label="照片" width="100">
<template #default="{ row }">
<div class="photo-cell" v-if="row.photos?.length">
<img
v-for="key in row.photos.slice(0,2)" :key="key"
:src="photoUrls[key] || ''"
class="mini-thumb"
@click="viewPhoto(photoUrls[key])"
/>
<img v-for="key in row.photos.slice(0,2)" :key="key"
:src="photoUrls[key] || ''" class="mini-thumb"
@click="viewPhoto(photoUrls[key])" />
</div>
<span v-else>-</span>
</template>
@@ -198,7 +211,15 @@ const notesByDate = computed(() => {
<el-tab-pane label="今日纪要" name="daily_notes">
<div v-if="report.daily_notes.length === 0" class="empty">暂无数据</div>
<div v-for="[date, items] in notesByDate" :key="date" class="date-group">
<h4 class="date-title">📅 {{ date }}</h4>
<h4 class="date-title">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-right:4px; color: var(--gold)">
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect>
<line x1="16" y1="2" x2="16" y2="6"></line>
<line x1="8" y1="2" x2="8" y2="6"></line>
<line x1="3" y1="10" x2="21" y2="10"></line>
</svg>
{{ date }}
</h4>
<el-table :data="items" stripe style="width:100%">
<el-table-column label="分类" width="100">
<template #default="{ row }">
@@ -233,25 +254,39 @@ const notesByDate = computed(() => {
</el-tabs>
</el-card>
<!-- Photo Viewer Dialog -->
<!-- Photo Viewer -->
<el-dialog v-model="photoDialogVisible" title="照片查看" width="80%">
<img :src="currentPhotoUrl" style="width:100%; border-radius:8px" />
<img :src="currentPhotoUrl" style="width:100%;" />
</el-dialog>
</div>
</template>
<style scoped>
.page-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 16px;
/* ═══ Page Header ═══ */
.page-head { margin-bottom: 20px; }
.page-head-row { display: flex; justify-content: space-between; align-items: flex-start; }
.page-title {
margin: 0;
font-family: 'ZCOOL XiaoWei', STSong, serif;
font-size: 22px; font-weight: 400;
color: var(--ink); letter-spacing: 0.06em;
}
.page-header h3 { margin: 0; }
.page-header p { margin: 4px 0 0; color: #909399; font-size: 13px; }
.page-sub {
margin: 2px 0 0;
font-family: 'JetBrains Mono', 'SF Mono', monospace;
font-size: 11px; color: var(--gold); letter-spacing: 0.08em;
}
.page-rule { width: 32px; height: 3px; background: var(--gold); margin-top: 12px; }
/* ═══ Content ═══ */
.date-group { margin-bottom: 20px; }
.date-title { margin: 12px 0 8px; font-size: 14px; color: #303133; }
.date-title {
display: flex; align-items: center;
margin: 12px 0 8px;
font-family: 'ZCOOL XiaoWei', STSong, serif;
font-size: 14px; color: var(--ink); letter-spacing: 0.04em;
}
.photo-cell { display: flex; gap: 4px; }
.mini-thumb { width: 36px; height: 36px; object-fit: cover; border-radius: 4px; cursor: pointer; }
.empty { text-align: center; color: #c0c4cc; padding: 40px 0; }
.mini-thumb { width: 36px; height: 36px; object-fit: cover; cursor: pointer; }
.empty { text-align: center; color: var(--c-text-muted); padding: 40px 0; font-family: 'Noto Serif SC', STSong, serif; }
</style>
+190 -22
View File
@@ -12,6 +12,12 @@ const submitLoading = ref(false)
const categories = ['行政事务', '合同整理', '发票处理', '内部会议', '培训学习', '其他']
const catColors: Record<string, string> = {
'行政事务': '#1C3738', '合同整理': '#4A6741', '发票处理': '#C4934A',
'内部会议': '#B8472E', '培训学习': '#5B7FA5', '其他': '#7B7568',
}
const timeRangeValue = ref<any>(null)
const form = ref({
note_date: todayStr(),
category: '其他',
@@ -19,6 +25,15 @@ const form = ref({
time_range: '',
})
function onTimeRangeChange(val: [string, string] | null) {
form.value.time_range = val ? val.join('-') : ''
}
function parseTimeRange(str: string): [string, string] | null {
if (!str || !str.includes('-')) return null
const parts = str.split('-')
return parts.length >= 2 ? [parts[0], parts[1]] as [string, string] : null
}
onMounted(async () => {
if (isEdit.value) {
try {
@@ -30,6 +45,7 @@ onMounted(async () => {
content: n.content || '',
time_range: n.time_range || '',
}
timeRangeValue.value = parseTimeRange(n.time_range)
} catch (_) {}
}
})
@@ -65,42 +81,194 @@ async function handleDelete() {
<template>
<div class="form-page">
<div class="form-header">
<el-button text @click="router.back()"> 返回</el-button>
<h3>{{ isEdit ? '编辑纪要' : '今日纪要' }}</h3>
<span style="width:60px"></span>
</div>
<el-form label-position="top">
<el-form-item label="日期">
<!-- Editorial Header -->
<header class="form-editorial-header">
<button type="button" class="form-back-btn" @click="router.back()">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="19" y1="12" x2="5" y2="12"></line>
<polyline points="12 19 5 12 12 5"></polyline>
</svg>
</button>
<div class="form-title-group">
<h2 class="form-title">{{ isEdit ? '编辑纪要' : '今日纪要' }}</h2>
<span class="form-subtitle">DAILY NOTE</span>
</div>
<div class="form-rule"></div>
</header>
<el-form label-position="top" class="editorial-form">
<el-form-item>
<template #label>
<span class="form-label">日期</span>
</template>
<el-date-picker v-model="form.note_date" type="date" style="width:100%" />
</el-form-item>
<el-form-item label="分类">
<el-form-item>
<template #label>
<span class="form-label">分类</span>
</template>
<div class="category-grid">
<el-button
v-for="c in categories" :key="c"
:type="form.category === c ? 'primary' : ''"
size="small"
<button
v-for="c in categories"
:key="c"
type="button"
class="cat-chip"
:class="{ 'cat-chip--active': form.category === c }"
:style="form.category === c ? { background: catColors[c], borderColor: catColors[c], color: '#fff' } : {}"
@click="form.category = c"
>{{ c }}</el-button>
>
{{ c }}
</button>
</div>
</el-form-item>
<el-form-item label="时间范围(可选)">
<el-input v-model="form.time_range" placeholder="如 9:00-11:00" />
<el-form-item>
<template #label>
<span class="form-label">时间范围 <span class="form-label-hint">可选</span></span>
</template>
<el-time-picker
v-model="timeRangeValue"
is-range
format="HH:mm"
value-format="HH:mm"
range-separator=""
start-placeholder="开始"
end-placeholder="结束"
style="width:100%"
@change="onTimeRangeChange"
/>
</el-form-item>
<el-form-item label="工作内容" required>
<el-form-item>
<template #label>
<span class="form-label">工作内容 <span class="form-label-required">*</span></span>
</template>
<el-input v-model="form.content" type="textarea" :rows="5" placeholder="今天做了什么..." />
</el-form-item>
<div style="margin-top:24px; display:flex; gap:12px">
<el-button type="primary" size="large" :loading="submitLoading" @click="handleSubmit" style="flex:1">提交</el-button>
<el-button v-if="isEdit" type="danger" size="large" @click="handleDelete">删除</el-button>
<div class="form-actions">
<button class="submit-btn" :disabled="submitLoading" @click="handleSubmit">
<span v-if="submitLoading" class="btn-loading"></span>
{{ isEdit ? '保存修改' : '提交纪要' }}
</button>
<button v-if="isEdit" type="button" class="delete-btn" @click="handleDelete">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="3 6 5 6 21 6"></polyline>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
</svg>
删除
</button>
</div>
</el-form>
</div>
</template>
<style scoped>
.form-page { max-width: 500px; margin: 0 auto; }
.form-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; }
.form-header h3 { margin: 0; }
/* ═══ Page ═══ */
.form-page { max-width: 100%; margin: 0 auto; padding-bottom: 20px; }
/* ═══ Editorial Header ═══ */
.form-editorial-header {
display: flex; align-items: flex-start; gap: 14px;
margin-bottom: 26px; flex-wrap: wrap;
}
.form-back-btn {
background: var(--surface); border: 1px solid var(--warm-border);
padding: 8px 10px; cursor: pointer; color: var(--warm-gray);
display: flex; align-items: center; transition: all var(--transition);
flex-shrink: 0; margin-top: 2px;
}
.form-back-btn:hover { color: var(--ink); border-color: var(--ink); }
.form-title-group {
display: flex; flex-direction: column; gap: 0; flex: 1;
}
.form-title {
margin: 0; font-family: 'ZCOOL XiaoWei', STSong, serif;
font-size: 24px; font-weight: 400; color: var(--ink);
letter-spacing: 0.06em; line-height: 1.3;
}
.form-subtitle {
font-family: 'JetBrains Mono', 'SF Mono', monospace;
font-size: 9px; color: var(--gold); letter-spacing: 0.2em;
}
.form-rule {
width: 100%; height: 2px; background: var(--warm-border);
margin-top: 6px; position: relative;
}
.form-rule::after {
content: ''; position: absolute; left: 0; top: 0;
width: 32px; height: 2px; background: var(--gold);
}
/* ═══ Form Labels ═══ */
.form-label {
font-family: 'ZCOOL XiaoWei', STSong, serif !important;
font-size: 14px !important; color: var(--ink) !important;
letter-spacing: 0.04em; font-weight: 500 !important;
}
.form-label-hint {
font-family: 'Noto Serif SC', STSong, serif;
font-size: 11px; color: var(--warm-gray);
letter-spacing: 0.03em; font-weight: 400; margin-left: 6px;
}
.form-label-required { color: var(--vermilion); margin-left: 2px; }
.editorial-form .el-form-item { margin-bottom: 20px; }
/* ═══ Category Grid ═══ */
.category-grid { display: flex; flex-wrap: wrap; gap: 8px; }
.cat-chip {
padding: 10px 16px;
background: var(--surface);
border: 1px solid var(--warm-border);
color: var(--ink);
font-family: 'Noto Serif SC', STSong, serif;
font-size: 13px;
letter-spacing: 0.03em;
cursor: pointer;
transition: all 0.25s;
}
.cat-chip:hover { border-color: var(--gold); }
.cat-chip--active { font-weight: 600; }
/* ═══ Actions ═══ */
.form-actions {
display: flex; gap: 10px; margin-top: 28px;
}
.submit-btn {
flex: 1; display: flex; align-items: center; justify-content: center; gap: 8px;
padding: 16px; background: var(--ink); color: #fff; border: none;
font-family: 'ZCOOL XiaoWei', STSong, serif;
font-size: 16px; letter-spacing: 0.08em; cursor: pointer; transition: all 0.25s;
}
.submit-btn:hover { background: var(--ink-light); }
.submit-btn:active { transform: scale(0.98); }
.submit-btn:disabled { opacity: 0.6; cursor: not-allowed; }
.delete-btn {
display: flex; align-items: center; gap: 6px;
padding: 16px 20px;
background: var(--surface); color: var(--vermilion);
border: 1px solid var(--vermilion);
font-family: 'Noto Serif SC', STSong, serif;
font-size: 14px; letter-spacing: 0.04em; cursor: pointer; transition: all 0.25s;
}
.delete-btn:hover { background: var(--vermilion); color: #fff; }
.btn-loading {
width: 16px; height: 16px;
border: 2px solid rgba(255,255,255,0.3);
border-top-color: #fff; border-radius: 50%;
animation: spin 0.6s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
</style>
+503 -64
View File
@@ -17,8 +17,13 @@ const loading = ref(false)
const todayTotal = computed(() => todayVisitCount.value + todayNoteCount.value)
const categoryColors: Record<string, string> = {
'行政事务': '#409EFF', '合同整理': '#67C23A', '发票处理': '#E6A23C',
'内部会议': '#F56C6C', '培训学习': '#909399', '其他': '#B37FEB',
'行政事务': '#1C3738', '合同整理': '#4A6741', '发票处理': '#C4934A',
'内部会议': '#B8472E', '培训学习': '#5B7FA5', '其他': '#7B7568',
}
const categoryGlyphs: Record<string, string> = {
'行政事务': '政', '合同整理': '约', '发票处理': '票',
'内部会议': '议', '培训学习': '学', '其他': '杂',
}
async function loadToday() {
@@ -52,85 +57,519 @@ onMounted(loadToday)
<template>
<div class="mobile-home">
<!-- Today Summary -->
<div class="today-badge">
<el-icon :size="48"><Notebook /></el-icon>
<div class="badge-text">
<div class="badge-count">{{ todayTotal }}</div>
<div class="badge-label">今日已录入拜访{{ todayVisitCount }} + 纪要{{ todayNoteCount }}</div>
<!-- Editorial Stat Banner -->
<div class="stat-banner">
<div class="stat-masthead">
<span class="stat-label-sm">TODAY</span>
<span class="stat-date">{{ new Date().toLocaleDateString('zh-CN', { month:'long', day:'numeric', weekday:'short' }) }}</span>
</div>
<div class="stat-main">
<span class="stat-number">{{ todayTotal }}</span>
<span class="stat-unit">条记录</span>
<div class="stat-breakdown">
<span class="stat-piece">拜访 <strong>{{ todayVisitCount }}</strong></span>
<span class="stat-sep">·</span>
<span class="stat-piece">纪要 <strong>{{ todayNoteCount }}</strong></span>
</div>
</div>
<!-- Decorative ink-wash corner -->
<div class="stat-ornament"></div>
</div>
<!-- Primary Quick Actions -->
<div class="quick-actions">
<el-button type="primary" size="large" @click="router.push('/m/visit/new')" style="flex:1">+ 今日拜访</el-button>
<el-button type="success" size="large" @click="router.push('/m/note/new')" style="flex:1">📝 今日纪要</el-button>
<button class="action-btn action-btn--primary" @click="router.push('/m/visit/new')">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="12" y1="5" x2="12" y2="19"></line>
<line x1="5" y1="12" x2="19" y2="12"></line>
</svg>
<span class="action-btn__text">今日拜访</span>
</button>
<button class="action-btn action-btn--secondary" @click="router.push('/m/note/new')">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path>
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path>
</svg>
<span class="action-btn__text">今日纪要</span>
</button>
</div>
<!-- Secondary Quick Links -->
<div class="quick-links">
<el-button @click="router.push('/m/work-plan/new')">📋 工作计划</el-button>
<el-button @click="router.push('/m/mini-biz/new')">💼 商机跟单</el-button>
<el-button @click="router.push('/m/key-visit/new')"> 要客拜访</el-button>
<button class="link-chip" @click="router.push('/m/work-plan/new')">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect>
<line x1="16" y1="2" x2="16" y2="6"></line>
<line x1="8" y1="2" x2="8" y2="6"></line>
<line x1="3" y1="10" x2="21" y2="10"></line>
</svg>
工作计划
</button>
<button class="link-chip" @click="router.push('/m/mini-biz/new')">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="12" y1="1" x2="12" y2="23"></line>
<path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"></path>
</svg>
商机跟单
</button>
<button class="link-chip" @click="router.push('/m/key-visit/new')">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"></polygon>
</svg>
要客拜访
</button>
</div>
<div v-loading="loading">
<!-- Daily Notes -->
<!-- Section Divider -->
<div class="section-head" v-if="dailyNotes.length || visits.length">
<span class="section-title">今日记录</span>
<span class="section-line"></span>
</div>
<div v-loading="loading" class="records-area">
<!-- Daily Notes -->
<template v-if="dailyNotes.length">
<h4>📝 今日纪要</h4>
<el-card v-for="n in dailyNotes" :key="n.id" class="note-card" shadow="hover">
<div class="card-header">
<el-tag :color="categoryColors[n.category]" effect="dark" size="small">{{ n.category }}</el-tag>
<span v-if="n.time_range" class="time">{{ n.time_range }}</span>
<article
v-for="(n, i) in dailyNotes"
:key="n.id"
class="record-card record-card--note"
:class="{ 'record-card--offset': i % 2 === 1 }"
@click="router.push(`/m/note/${n.id}/edit`)"
>
<div class="record-accent" :style="{ background: categoryColors[n.category] || '#7B7568' }"></div>
<div class="record-body">
<div class="record-header">
<span class="record-glyph" :style="{ background: categoryColors[n.category] || '#7B7568' }">
{{ categoryGlyphs[n.category] || '记' }}
</span>
<span class="record-category">{{ n.category }}</span>
<span v-if="n.time_range" class="record-time">{{ n.time_range }}</span>
</div>
<p class="record-content">{{ n.content }}</p>
<div class="record-footer" v-if="n.time_range">
<span class="record-time">{{ n.time_range }}</span>
</div>
</div>
<div class="card-body">{{ n.content }}</div>
<div class="card-footer">
<el-button text size="small" @click="router.push(`/m/note/${n.id}/edit`)">编辑</el-button>
</div>
</el-card>
</article>
</template>
<!-- Visits -->
<h4 v-if="visits.length">🏢 今日拜访记录</h4>
<div v-if="visits.length === 0 && dailyNotes.length === 0" class="empty">今日暂无记录点击上方按钮开始填报</div>
<el-card v-for="v in visits" :key="v.id" class="visit-card" shadow="hover">
<div class="card-header">
<span class="customer-name">{{ v.customer_name || '-' }}</span>
<el-tag size="small">{{ v.visit_method }}</el-tag>
</div>
<div class="card-body">
<div>{{ v.communication_content || '暂无沟通内容' }}</div>
<div v-if="v.customer_demand" class="demand">📌 需求: {{ v.customer_demand }}</div>
</div>
<div class="card-photos" v-if="v.photos?.length">
<img v-for="key in v.photos" :key="key" :src="photoUrls[key] || ''" class="photo-thumb" />
</div>
<div class="card-footer">
<span class="time">{{ v.time_range }}</span>
<el-button text size="small" @click="router.push(`/m/visit/${v.id}/edit`)">编辑</el-button>
</div>
</el-card>
<!-- Visit Records -->
<template v-if="visits.length">
<article
v-for="(v, i) in visits"
:key="v.id"
class="record-card record-card--visit"
:class="{ 'record-card--offset': (dailyNotes.length + i) % 2 === 1 }"
@click="router.push(`/m/visit/${v.id}/edit`)"
>
<div class="record-accent record-accent--gold"></div>
<div class="record-body">
<div class="record-header">
<span class="record-glyph record-glyph--visit">访</span>
<strong class="record-customer">{{ v.customer_name || '未知客户' }}</strong>
<span class="record-badge" :class="'badge--' + v.visit_method">{{ v.visit_method }}</span>
</div>
<p class="record-content">{{ v.communication_content || '暂无沟通内容' }}</p>
<p v-if="v.customer_demand" class="record-demand">
<span class="demand-marker"></span> {{ v.customer_demand }}
</p>
<div v-if="v.photos?.length" class="record-photos">
<img v-for="key in v.photos" :key="key" :src="photoUrls[key] || ''" class="record-photo" />
</div>
<div class="record-footer" v-if="v.time_range">
<span class="record-time">{{ v.time_range }}</span>
</div>
</div>
</article>
</template>
<!-- Empty State -->
<div v-if="visits.length === 0 && dailyNotes.length === 0 && !loading" class="empty-state">
<div class="empty-glyph"></div>
<p class="empty-text">今日暂无记录</p>
<p class="empty-hint">点击上方按钮开始填报</p>
</div>
</div>
</div>
</template>
<style scoped>
.mobile-home { padding-bottom: 20px; }
.today-badge {
display: flex; align-items: center; gap: 16px;
background: linear-gradient(135deg, #e8f0fe, #f0f5ff); border-radius: 16px; padding: 20px; margin-bottom: 16px;
/* ═══ Page ═══ */
.mobile-home {
padding-bottom: 24px;
max-width: 100%;
position: relative;
}
/* ═══ Stat Banner — Editorial Masthead ═══ */
.stat-banner {
position: relative;
background: var(--surface);
border: 1px solid var(--warm-border);
padding: 20px 22px;
margin-bottom: 18px;
overflow: hidden;
}
.stat-masthead {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
}
.stat-label-sm {
font-family: 'JetBrains Mono', 'SF Mono', monospace;
font-size: 10px;
letter-spacing: 0.2em;
color: var(--warm-gray);
}
.stat-date {
font-family: 'Noto Serif SC', STSong, serif;
font-size: 12px;
color: var(--warm-gray);
letter-spacing: 0.04em;
}
.stat-main {
display: flex;
align-items: baseline;
gap: 8px;
flex-wrap: wrap;
}
.stat-number {
font-family: 'ZCOOL XiaoWei', STSong, serif;
font-size: 56px;
color: var(--ink);
line-height: 1;
letter-spacing: 0.04em;
}
.stat-unit {
font-family: 'Noto Serif SC', STSong, serif;
font-size: 16px;
color: var(--warm-gray);
letter-spacing: 0.06em;
}
.stat-breakdown {
width: 100%;
display: flex;
gap: 10px;
align-items: center;
margin-top: 6px;
}
.stat-piece {
font-family: 'Noto Serif SC', STSong, serif;
font-size: 13px;
color: var(--warm-gray);
letter-spacing: 0.03em;
}
.stat-piece strong {
font-family: 'JetBrains Mono', 'SF Mono', monospace;
color: var(--ink);
font-size: 15px;
}
.stat-sep {
color: var(--gold);
font-weight: 300;
}
/* Decorative gold slash */
.stat-ornament {
position: absolute;
top: -20px;
right: -20px;
width: 80px;
height: 80px;
background: linear-gradient(135deg, transparent 50%, rgba(196,147,74,0.08) 50%);
pointer-events: none;
}
/* ═══ Quick Actions ═══ */
.quick-actions {
display: flex;
gap: 10px;
margin-bottom: 12px;
}
.action-btn {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 16px 12px;
border: 1px solid var(--warm-border);
cursor: pointer;
font-family: 'Noto Serif SC', STSong, serif;
font-size: 15px;
letter-spacing: 0.04em;
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
position: relative;
}
.action-btn:active {
transform: scale(0.97);
}
.action-btn--primary {
background: var(--ink);
color: #fff;
border-color: var(--ink);
}
.action-btn--primary:hover {
background: var(--ink-light);
}
.action-btn--secondary {
background: var(--surface);
color: var(--ink);
border-color: var(--ink);
}
.action-btn--secondary:hover {
background: var(--c-primary-bg);
}
.action-btn__text {
font-weight: 500;
}
/* ═══ Quick Links (Chips) ═══ */
.quick-links {
display: flex;
gap: 8px;
margin-bottom: 22px;
flex-wrap: wrap;
}
.link-chip {
display: flex;
align-items: center;
gap: 5px;
padding: 9px 15px;
background: var(--surface);
border: 1px solid var(--warm-border);
color: var(--ink);
font-family: 'Noto Serif SC', STSong, serif;
font-size: 13px;
letter-spacing: 0.03em;
cursor: pointer;
transition: all 0.2s;
}
.link-chip:hover {
border-color: var(--gold);
color: var(--vermilion);
}
.link-chip:active {
background: var(--paper-dark);
}
/* ═══ Section Head ═══ */
.section-head {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 14px;
}
.section-title {
font-family: 'ZCOOL XiaoWei', STSong, serif;
font-size: 18px;
color: var(--ink);
letter-spacing: 0.06em;
white-space: nowrap;
}
.section-line {
flex: 1;
height: 2px;
background: var(--gold);
}
/* ═══ Record Cards ═══ */
.records-area {
display: flex;
flex-direction: column;
gap: 12px;
}
.record-card {
background: var(--surface);
border: 1px solid var(--warm-border);
display: flex;
cursor: pointer;
transition: transform 0.2s, box-shadow 0.2s;
}
.record-card:active { transform: scale(0.99); }
.record-card--offset {
margin-left: 16px;
}
.record-card:hover {
box-shadow: 0 4px 12px rgba(28,55,56,0.06);
}
/* Left accent stripe */
.record-accent {
width: 4px;
flex-shrink: 0;
}
.record-accent--gold {
background: var(--gold);
}
.record-body {
flex: 1;
padding: 14px 16px;
min-width: 0;
}
.record-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
flex-wrap: wrap;
}
/* Category glyph — single Chinese character badge */
.record-glyph {
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
color: #fff;
font-family: 'ZCOOL XiaoWei', STSong, serif;
font-size: 13px;
flex-shrink: 0;
}
.record-glyph--visit {
background: var(--gold);
}
.record-category {
font-family: 'ZCOOL XiaoWei', STSong, serif;
font-size: 13px;
color: var(--ink);
letter-spacing: 0.04em;
font-weight: 600;
}
.record-badge {
font-family: 'Noto Serif SC', STSong, serif;
font-size: 11px;
padding: 1px 8px;
letter-spacing: 0.04em;
margin-left: auto;
border: 1px solid;
}
.badge--上门 { color: #4A6741; border-color: #4A6741; }
.badge--电话 { color: #5B7FA5; border-color: #5B7FA5; }
.badge--微信 { color: #22c55e; border-color: #22c55e; }
.badge--出差 { color: #C4934A; border-color: #C4934A; }
.record-customer {
font-family: 'ZCOOL XiaoWei', STSong, serif;
font-size: 15px;
color: var(--ink);
letter-spacing: 0.03em;
flex: 1;
}
.record-time {
font-family: 'JetBrains Mono', 'SF Mono', monospace;
font-size: 11px;
color: var(--warm-gray);
letter-spacing: 0.03em;
margin-left: auto;
}
.record-content {
font-family: 'Noto Serif SC', STSong, serif;
font-size: 14px;
color: var(--c-text);
line-height: 1.7;
margin: 0 0 8px;
word-break: break-all;
}
.record-demand {
font-family: 'Noto Serif SC', STSong, serif;
font-size: 13px;
color: var(--vermilion);
margin: 0 0 8px;
line-height: 1.6;
}
.demand-marker {
color: var(--gold);
font-size: 10px;
}
.record-photos {
display: flex;
gap: 6px;
margin: 8px 0;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
padding-bottom: 2px;
}
.record-photo {
width: 68px;
height: 68px;
object-fit: cover;
flex-shrink: 0;
border: 2px solid var(--warm-border);
}
.record-footer {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 8px;
}
/* ═══ Empty State ═══ */
.empty-state {
text-align: center;
padding: 48px 0;
}
.empty-glyph {
font-family: 'ZCOOL XiaoWei', STSong, serif;
font-size: 40px;
color: var(--gold);
opacity: 0.4;
margin-bottom: 8px;
}
.empty-text {
font-family: 'Noto Serif SC', STSong, serif;
font-size: 15px;
color: var(--warm-gray);
margin: 0 0 4px;
}
.empty-hint {
font-family: 'Noto Serif SC', STSong, serif;
font-size: 12px;
color: var(--c-text-muted);
margin: 0;
}
.badge-count { font-size: 36px; font-weight: 700; color: #1a73e8; }
.badge-label { font-size: 13px; color: #606266; }
.quick-actions { display: flex; gap: 12px; margin-bottom: 12px; }
.quick-links { display: flex; gap: 8px; margin-bottom: 20px; }
h4 { margin: 16px 0 12px; color: #303133; }
.empty { text-align: center; color: #c0c4cc; padding: 40px 0; }
.visit-card, .note-card { margin-bottom: 12px; }
.card-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; }
.customer-name { font-weight: 600; font-size: 15px; }
.demand { color: #e6a23c; margin-top: 4px; font-size: 13px; }
.card-photos { display: flex; gap: 6px; margin-top: 8px; overflow-x: auto; }
.photo-thumb { width: 72px; height: 72px; object-fit: cover; border-radius: 8px; }
.card-footer { display: flex; justify-content: space-between; align-items: center; margin-top: 8px; font-size: 12px; color: #909399; }
.card-body { font-size: 14px; color: #303133; }
.time { font-size: 12px; color: #909399; }
</style>
+164 -24
View File
@@ -4,11 +4,19 @@ import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { keyVisitsApi } from '@/api/keyVisits'
import { customersApi } from '@/api/customers'
import api from '@/api/index'
const router = useRouter()
const submitLoading = ref(false)
const customers = ref<any[]>([])
const allUsers = ref<any[]>([])
const urgencyLevels = ['一般', '重要', '紧急']
const urgencyColors: Record<string, string> = {
'一般': '#5B7FA5', '重要': '#C4934A', '紧急': '#B8472E',
}
const plannedVisitors = ref<string[]>([])
const form = ref({
customer_id: '',
urgency_level: '一般',
@@ -19,7 +27,13 @@ const form = ref({
visit_target: '',
})
onMounted(() => { loadCustomers() })
function onVisitorsChange(val: string[]) {
form.value.planned_visitor = val.join('、')
}
onMounted(async () => {
await Promise.all([loadCustomers(), loadUsers()])
})
async function loadCustomers(q?: string) {
try {
@@ -30,6 +44,13 @@ async function loadCustomers(q?: string) {
} catch (_) {}
}
async function loadUsers() {
try {
const res = await api.get('/users/')
allUsers.value = res.data || []
} catch (_) {}
}
async function handleSubmit() {
if (!form.value.customer_id) { ElMessage.warning('请选择客户'); return }
submitLoading.value = true
@@ -45,50 +66,169 @@ async function handleSubmit() {
<template>
<div class="form-page">
<div class="form-header">
<el-button text @click="router.back()"> 返回</el-button>
<h3>添加要客拜访</h3>
<span style="width:60px"></span>
</div>
<el-form label-position="top">
<el-form-item label="客户单位">
<header class="form-editorial-header">
<button type="button" class="form-back-btn" @click="router.back()">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="19" y1="12" x2="5" y2="12"></line>
<polyline points="12 19 5 12 12 5"></polyline>
</svg>
</button>
<div class="form-title-group">
<h2 class="form-title">添加要客拜访</h2>
<span class="form-subtitle">KEY VISIT PLAN</span>
</div>
<div class="form-rule"></div>
</header>
<el-form label-position="top" class="editorial-form">
<el-form-item>
<template #label><span class="form-label">客户单位</span></template>
<el-select v-model="form.customer_id" filterable remote :remote-method="loadCustomers" placeholder="搜索客户" style="width:100%">
<el-option v-for="c in customers" :key="c.id" :label="c.name" :value="c.id" />
</el-select>
</el-form-item>
<el-form-item label="紧急重要度">
<el-radio-group v-model="form.urgency_level">
<el-radio-button value="一般">一般</el-radio-button>
<el-radio-button value="重要">重要</el-radio-button>
<el-radio-button value="紧急">紧急</el-radio-button>
</el-radio-group>
<el-form-item>
<template #label><span class="form-label">紧急重要度</span></template>
<div class="urgency-grid">
<button
v-for="u in urgencyLevels" :key="u"
type="button"
class="urgency-chip"
:class="{ 'urgency-chip--active': form.urgency_level === u }"
:style="form.urgency_level === u ? { background: urgencyColors[u], borderColor: urgencyColors[u], color: '#fff' } : {}"
@click="form.urgency_level = u"
>{{ u }}</button>
</div>
</el-form-item>
<el-form-item label="内容描述">
<el-form-item>
<template #label><span class="form-label">内容描述</span></template>
<el-input v-model="form.description" type="textarea" :rows="4" placeholder="拜访目的和内容..." />
</el-form-item>
<el-form-item label="进展状态">
<el-form-item>
<template #label><span class="form-label">进展状态</span></template>
<el-select v-model="form.progress_status" style="width:100%">
<el-option label="未开始" value="未开始" />
<el-option label="进行中" value="进行中" />
<el-option label="已完成" value="已完成" />
</el-select>
</el-form-item>
<el-form-item label="计划拜访时间">
<el-form-item>
<template #label><span class="form-label">计划拜访时间</span></template>
<el-input v-model="form.planned_date" placeholder="如: 2026-06-25" />
</el-form-item>
<el-form-item label="计划拜访人">
<el-input v-model="form.planned_visitor" placeholder="负责拜访的人员" />
<el-form-item>
<template #label><span class="form-label">计划拜访人 <span class="form-label-hint">可多选</span></span></template>
<el-select
v-model="plannedVisitors"
multiple
filterable
allow-create
default-first-option
placeholder="选择或输入姓名(可多选)"
style="width:100%"
@change="onVisitorsChange"
>
<el-option v-for="u in allUsers" :key="u.id" :label="u.name + (u.role === 'director' ? ' (支局长)' : u.role === 'leader' ? ' (分管领导)' : '')" :value="u.name" />
</el-select>
</el-form-item>
<el-form-item label="拜访对象">
<el-form-item>
<template #label><span class="form-label">拜访对象</span></template>
<el-input v-model="form.visit_target" placeholder="如: 王局长、张处长" />
</el-form-item>
<el-button type="primary" size="large" :loading="submitLoading" @click="handleSubmit" style="width:100%">提交</el-button>
<button class="submit-btn" :disabled="submitLoading" @click="handleSubmit">
<span v-if="submitLoading" class="btn-loading"></span>
提交计划
</button>
</el-form>
</div>
</template>
<style scoped>
.form-page { max-width: 500px; margin: 0 auto; }
.form-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; }
.form-header h3 { margin: 0; }
.form-page { max-width: 100%; margin: 0 auto; padding-bottom: 20px; }
.form-editorial-header {
display: flex; align-items: flex-start; gap: 14px;
margin-bottom: 26px; flex-wrap: wrap;
}
.form-back-btn {
background: var(--surface); border: 1px solid var(--warm-border);
padding: 8px 10px; cursor: pointer; color: var(--warm-gray);
display: flex; align-items: center; transition: all var(--transition);
flex-shrink: 0; margin-top: 2px;
}
.form-back-btn:hover { color: var(--ink); border-color: var(--ink); }
.form-title-group { display: flex; flex-direction: column; gap: 0; flex: 1; }
.form-title {
margin: 0; font-family: 'ZCOOL XiaoWei', STSong, serif;
font-size: 24px; font-weight: 400; color: var(--ink);
letter-spacing: 0.06em; line-height: 1.3;
}
.form-subtitle {
font-family: 'JetBrains Mono', 'SF Mono', monospace;
font-size: 9px; color: var(--gold); letter-spacing: 0.2em;
}
.form-rule {
width: 100%; height: 2px; background: var(--warm-border);
margin-top: 6px; position: relative;
}
.form-rule::after {
content: ''; position: absolute; left: 0; top: 0;
width: 32px; height: 2px; background: var(--gold);
}
.form-label {
font-family: 'ZCOOL XiaoWei', STSong, serif !important;
font-size: 14px !important; color: var(--ink) !important;
letter-spacing: 0.04em; font-weight: 500 !important;
}
.editorial-form .el-form-item { margin-bottom: 20px; }
/* ═══ Urgency Chips ═══ */
.urgency-grid { display: flex; gap: 8px; flex-wrap: wrap; }
.urgency-chip {
flex: 1; min-width: 72px;
padding: 10px 8px;
background: var(--surface);
border: 1px solid var(--warm-border);
font-family: 'Noto Serif SC', STSong, serif;
font-size: 13px; letter-spacing: 0.04em;
cursor: pointer; transition: all 0.25s;
text-align: center;
}
.urgency-chip:hover { border-color: var(--ink); }
.urgency-chip--active { font-weight: 600; }
.submit-btn {
width: 100%; display: flex; align-items: center; justify-content: center; gap: 8px;
padding: 16px; background: var(--ink); color: #fff; border: none;
font-family: 'ZCOOL XiaoWei', STSong, serif;
font-size: 16px; letter-spacing: 0.08em; cursor: pointer; transition: all 0.25s;
margin-top: 24px;
}
.submit-btn:hover { background: var(--ink-light); }
.submit-btn:active { transform: scale(0.98); }
.submit-btn:disabled { opacity: 0.6; cursor: not-allowed; }
.btn-loading {
width: 16px; height: 16px;
border: 2px solid rgba(255,255,255,0.3);
border-top-color: #fff; border-radius: 50%;
animation: spin 0.6s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
</style>
+100 -16
View File
@@ -44,43 +44,127 @@ async function handleSubmit() {
<template>
<div class="form-page">
<div class="form-header">
<el-button text @click="router.back()"> 返回</el-button>
<h3>添加商机跟单</h3>
<span style="width:60px"></span>
</div>
<el-form label-position="top">
<el-form-item label="客户单位">
<header class="form-editorial-header">
<button class="form-back-btn" @click="router.back()">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="19" y1="12" x2="5" y2="12"></line>
<polyline points="12 19 5 12 12 5"></polyline>
</svg>
</button>
<div class="form-title-group">
<h2 class="form-title">添加商机跟单</h2>
<span class="form-subtitle">BUSINESS OPPORTUNITY</span>
</div>
<div class="form-rule"></div>
</header>
<el-form label-position="top" class="editorial-form">
<el-form-item>
<template #label><span class="form-label">客户单位</span></template>
<el-select v-model="form.customer_id" filterable remote :remote-method="loadCustomers" placeholder="搜索客户" style="width:100%">
<el-option v-for="c in customers" :key="c.id" :label="c.name" :value="c.id" />
</el-select>
</el-form-item>
<el-form-item label="产品类型">
<el-form-item>
<template #label><span class="form-label">产品类型</span></template>
<el-input v-model="form.product_type" placeholder="例如: 云桌面、专线..." />
</el-form-item>
<el-form-item label="金额">
<el-form-item>
<template #label><span class="form-label">金额</span></template>
<el-input v-model="form.amount" placeholder="预估金额" />
</el-form-item>
<el-form-item label="跟进内容">
<el-form-item>
<template #label><span class="form-label">跟进内容</span></template>
<el-input v-model="form.follow_up_detail" type="textarea" :rows="4" placeholder="具体情况..." />
</el-form-item>
<el-form-item label="跟进状态">
<el-form-item>
<template #label><span class="form-label">跟进状态</span></template>
<el-select v-model="form.status" style="width:100%">
<el-option label="跟进中" value="跟进中" />
<el-option label="已签约" value="已签约" />
<el-option label="已流失" value="已流失" />
</el-select>
</el-form-item>
<el-form-item label="预计列收时间">
<el-form-item>
<template #label><span class="form-label">预计列收时间</span></template>
<el-input v-model="form.expected_revenue_date" placeholder="如: 2026Q3" />
</el-form-item>
<el-button type="primary" size="large" :loading="submitLoading" @click="handleSubmit" style="width:100%">提交</el-button>
<button class="submit-btn" :disabled="submitLoading" @click="handleSubmit">
<span v-if="submitLoading" class="btn-loading"></span>
提交商机
</button>
</el-form>
</div>
</template>
<style scoped>
.form-page { max-width: 500px; margin: 0 auto; }
.form-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; }
.form-header h3 { margin: 0; }
.form-page { max-width: 100%; margin: 0 auto; padding-bottom: 20px; }
.form-editorial-header {
display: flex; align-items: flex-start; gap: 14px;
margin-bottom: 26px; flex-wrap: wrap;
}
.form-back-btn {
background: var(--surface); border: 1px solid var(--warm-border);
padding: 8px 10px; cursor: pointer; color: var(--warm-gray);
display: flex; align-items: center; transition: all var(--transition);
flex-shrink: 0; margin-top: 2px;
}
.form-back-btn:hover { color: var(--ink); border-color: var(--ink); }
.form-title-group { display: flex; flex-direction: column; gap: 0; flex: 1; }
.form-title {
margin: 0; font-family: 'ZCOOL XiaoWei', STSong, serif;
font-size: 24px; font-weight: 400; color: var(--ink);
letter-spacing: 0.06em; line-height: 1.3;
}
.form-subtitle {
font-family: 'JetBrains Mono', 'SF Mono', monospace;
font-size: 9px; color: var(--gold); letter-spacing: 0.2em;
}
.form-rule {
width: 100%; height: 2px; background: var(--warm-border);
margin-top: 6px; position: relative;
}
.form-rule::after {
content: ''; position: absolute; left: 0; top: 0;
width: 32px; height: 2px; background: var(--gold);
}
.form-label {
font-family: 'ZCOOL XiaoWei', STSong, serif !important;
font-size: 14px !important; color: var(--ink) !important;
letter-spacing: 0.04em; font-weight: 500 !important;
}
.editorial-form .el-form-item { margin-bottom: 20px; }
.submit-btn {
width: 100%; display: flex; align-items: center; justify-content: center; gap: 8px;
padding: 16px; background: var(--ink); color: #fff; border: none;
font-family: 'ZCOOL XiaoWei', STSong, serif;
font-size: 16px; letter-spacing: 0.08em; cursor: pointer; transition: all 0.25s;
margin-top: 24px;
}
.submit-btn:hover { background: var(--ink-light); }
.submit-btn:active { transform: scale(0.98); }
.submit-btn:disabled { opacity: 0.6; cursor: not-allowed; }
.btn-loading {
width: 16px; height: 16px;
border: 2px solid rgba(255,255,255,0.3);
border-top-color: #fff; border-radius: 50%;
animation: spin 0.6s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
</style>
+402 -54
View File
@@ -21,16 +21,19 @@ const form = ref({
visit_date: todayStr(),
visit_method: '上门',
time_range: '',
visitor_name: '',
visitor_phone: '',
communication_content: '',
customer_demand: '',
companions: [] as string[],
photos: [] as string[],
})
const timeRangeValue = ref<any>(null)
const customers = ref<any[]>([])
const managers = ref<any[]>([])
const uploadedPhotos = ref<string[]>([])
const photoPreviews = ref<string[]>([]) // blob URLs for local preview
const photoPreviews = ref<string[]>([])
const uploading = ref(false)
const customerSearch = ref('')
@@ -49,13 +52,19 @@ onMounted(async () => {
visit_date: v.visit_date,
visit_method: v.visit_method,
time_range: v.time_range,
visitor_name: v.visitor_name || '',
visitor_phone: v.visitor_phone || '',
communication_content: v.communication_content || '',
customer_demand: v.customer_demand || '',
companions: (v.companions || []).map(String),
photos: v.photos || [],
}
uploadedPhotos.value = v.photos || []
// Load photo previews for existing photos
// Parse time range back into picker
if (v.time_range && v.time_range.includes('-')) {
const parts = v.time_range.split('-')
timeRangeValue.value = [parts[0], parts[1]]
}
for (const key of (v.photos || [])) {
try {
const urlRes = await uploadApi.getDownloadUrl(key)
@@ -77,11 +86,8 @@ async function loadCustomers(q?: string) {
async function handleCustomerSearch(query: string) {
customerSearch.value = query
if (query) {
await loadCustomers(query)
} else {
await loadCustomers()
}
if (query) { await loadCustomers(query) }
else { await loadCustomers() }
}
async function handleQuickCreate() {
@@ -108,7 +114,6 @@ async function handlePhotoUpload(event: Event) {
uploading.value = true
for (const file of Array.from(target.files)) {
if (uploadedPhotos.value.length >= 9) break
// Show local preview immediately
const previewUrl = URL.createObjectURL(file)
photoPreviews.value.push(previewUrl)
try {
@@ -117,7 +122,6 @@ async function handlePhotoUpload(event: Event) {
uploadedPhotos.value.push(res.data.object_key)
form.value.photos = [...uploadedPhotos.value]
} catch (e: any) {
// Remove preview on failure
const idx = photoPreviews.value.indexOf(previewUrl)
if (idx >= 0) photoPreviews.value.splice(idx, 1)
URL.revokeObjectURL(previewUrl)
@@ -128,8 +132,11 @@ async function handlePhotoUpload(event: Event) {
target.value = ''
}
function onTimeRangeChange(val: [string, string] | null) {
form.value.time_range = val ? val.join('-') : ''
}
function removePhoto(index: number) {
// Clean up preview URL
if (index < photoPreviews.value.length) {
URL.revokeObjectURL(photoPreviews.value[index])
photoPreviews.value.splice(index, 1)
@@ -169,15 +176,27 @@ async function handleDelete() {
</script>
<template>
<div class="visit-form">
<div class="form-header">
<el-button text @click="router.back()"> 返回</el-button>
<h3>{{ isEdit ? '编辑拜访记录' : '今日拜访' }}</h3>
<span style="width:60px"></span>
</div>
<div class="form-page">
<!-- Editorial Header -->
<header class="form-editorial-header">
<button type="button" class="form-back-btn" @click="router.back()">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="19" y1="12" x2="5" y2="12"></line>
<polyline points="12 19 5 12 12 5"></polyline>
</svg>
</button>
<div class="form-title-group">
<h2 class="form-title">{{ isEdit ? '编辑拜访记录' : '今日拜访' }}</h2>
<span class="form-subtitle">VISIT RECORD</span>
</div>
<div class="form-rule"></div>
</header>
<el-form label-position="top">
<el-form-item label="客户单位">
<el-form label-position="top" class="editorial-form">
<el-form-item>
<template #label>
<span class="form-label">客户单位</span>
</template>
<el-select
v-model="form.customer_id"
filterable
@@ -185,85 +204,414 @@ async function handleDelete() {
:remote-method="handleCustomerSearch"
placeholder="搜索/选择客户"
style="width:100%"
popper-class="customer-select-popper"
>
<el-option v-for="c in customers" :key="c.id" :label="c.name" :value="c.id" />
</el-select>
<div v-if="customerSearch && !customers.some(c => c.name === customerSearch)" style="margin-top:6px">
<el-button size="small" type="success" @click="handleQuickCreate">
+ 新建客户{{ customerSearch }}
</el-button>
<div v-if="customerSearch && !customers.some(c => c.name === customerSearch)" style="margin-top:8px">
<button type="button" class="quick-create-btn" @click="handleQuickCreate">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="12" y1="5" x2="12" y2="19"></line>
<line x1="5" y1="12" x2="19" y2="12"></line>
</svg>
新建客户{{ customerSearch }}
</button>
</div>
</el-form-item>
<el-form-item label="拜访日期">
<el-form-item>
<template #label>
<span class="form-label">拜访日期</span>
</template>
<el-date-picker v-model="form.visit_date" type="date" style="width:100%" />
</el-form-item>
<el-form-item label="拜访方式">
<el-radio-group v-model="form.visit_method">
<el-radio-button value="上门">上门</el-radio-button>
<el-radio-button value="电话">电话</el-radio-button>
<el-radio-button value="微信">微信</el-radio-button>
<el-radio-button value="出差">出差</el-radio-button>
</el-radio-group>
<el-form-item>
<template #label>
<span class="form-label">拜访方式</span>
</template>
<div class="method-grid">
<button
v-for="m in ['上门','电话','微信','出差']"
:key="m"
type="button"
class="method-chip"
:class="{ 'method-chip--active': form.visit_method === m, ['method--' + m]: true }"
@click="form.visit_method = m"
>{{ m }}</button>
</div>
</el-form-item>
<el-form-item label="时间范围">
<el-input v-model="form.time_range" placeholder="如 9:00-10:00" />
<el-form-item>
<template #label>
<span class="form-label">时间范围</span>
</template>
<el-time-picker
v-model="timeRangeValue"
is-range
format="HH:mm"
value-format="HH:mm"
range-separator=""
start-placeholder="开始"
end-placeholder="结束"
style="width:100%"
@change="onTimeRangeChange"
/>
</el-form-item>
<el-form-item label="同访人员">
<el-form-item>
<template #label>
<span class="form-label">同访人员</span>
</template>
<el-select v-model="form.companions" multiple filterable placeholder="选择同访人员" style="width:100%">
<el-option v-for="m in managers" :key="m.id" :label="m.name" :value="m.id" :disabled="m.id === auth.userId" />
</el-select>
</el-form-item>
<el-form-item label="沟通内容">
<el-form-item>
<template #label>
<span class="form-label">拜访人姓名</span>
</template>
<el-input v-model="form.visitor_name" placeholder="实际拜访人姓名" />
</el-form-item>
<el-form-item>
<template #label>
<span class="form-label">拜访人电话 <span class="form-label-hint">可选</span></span>
</template>
<el-input v-model="form.visitor_phone" placeholder="联系电话(可选)" />
</el-form-item>
<el-form-item>
<template #label>
<span class="form-label">沟通内容</span>
</template>
<el-input v-model="form.communication_content" type="textarea" :rows="4" placeholder="本次拜访沟通内容..." />
</el-form-item>
<el-form-item label="客户需求">
<el-form-item>
<template #label>
<span class="form-label">客户需求</span>
</template>
<el-input v-model="form.customer_demand" type="textarea" :rows="2" placeholder="客户提出的需求..." />
</el-form-item>
<el-form-item label="拜访照片 (最多9张)">
<el-form-item>
<template #label>
<span class="form-label">拜访照片 <span class="form-label-hint">最多9张</span></span>
</template>
<div class="photo-area">
<div v-for="(key, idx) in uploadedPhotos" :key="key" class="photo-item">
<img :src="photoPreviews[idx]" class="photo-thumb" v-if="photoPreviews[idx]" />
<span class="photo-label">照片{{ idx + 1 }}</span>
<el-button text size="small" type="danger" @click="removePhoto(idx)">移除</el-button>
<button type="button" class="photo-remove-btn" @click="removePhoto(idx)">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
</div>
<label v-if="uploadedPhotos.length < 9" class="upload-btn">
<el-button :loading="uploading" type="primary" plain>+ 拍照/选图</el-button>
<span class="upload-btn__inner">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
<polyline points="17 8 12 3 7 8"></polyline>
<line x1="12" y1="3" x2="12" y2="15"></line>
</svg>
{{ uploading ? '上传中...' : '拍照/选图' }}
</span>
<input type="file" accept="image/*" multiple @change="handlePhotoUpload" class="file-input-hidden" />
</label>
</div>
</el-form-item>
<div style="margin-top:24px; display:flex; gap:12px">
<el-button type="primary" size="large" :loading="submitLoading" @click="handleSubmit" style="flex:1">
提交
</el-button>
<el-button v-if="isEdit" type="danger" size="large" :loading="deleting" @click="handleDelete">
<div class="form-actions">
<button class="submit-btn" :disabled="submitLoading" @click="handleSubmit">
<span v-if="submitLoading" class="btn-loading"></span>
{{ isEdit ? '保存修改' : '提交记录' }}
</button>
<button v-if="isEdit" type="button" class="delete-btn" :disabled="deleting" @click="handleDelete">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="3 6 5 6 21 6"></polyline>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
</svg>
删除
</el-button>
</button>
</div>
</el-form>
</div>
</template>
<style scoped>
.visit-form { max-width: 500px; margin: 0 auto; }
.form-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; }
.form-header h3 { margin: 0; }
.photo-area { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
.photo-item { display: flex; align-items: center; gap: 6px; background: #f5f7fa; padding: 4px 10px; border-radius: 8px; }
.photo-thumb { width: 48px; height: 48px; object-fit: cover; border-radius: 6px; flex-shrink: 0; }
.photo-label { font-size: 12px; }
/* ═══ Page ═══ */
.form-page {
max-width: 100%;
margin: 0 auto;
padding-bottom: 20px;
}
/* ═══ Editorial Header ═══ */
.form-editorial-header {
display: flex;
align-items: flex-start;
gap: 14px;
margin-bottom: 26px;
flex-wrap: wrap;
}
.form-back-btn {
background: var(--surface);
border: 1px solid var(--warm-border);
padding: 8px 10px;
cursor: pointer;
color: var(--warm-gray);
display: flex;
align-items: center;
transition: all var(--transition);
flex-shrink: 0;
margin-top: 2px;
}
.form-back-btn:hover {
color: var(--ink);
border-color: var(--ink);
}
.form-title-group {
display: flex;
flex-direction: column;
gap: 0;
flex: 1;
}
.form-title {
margin: 0;
font-family: 'ZCOOL XiaoWei', STSong, serif;
font-size: 24px;
font-weight: 400;
color: var(--ink);
letter-spacing: 0.06em;
line-height: 1.3;
}
.form-subtitle {
font-family: 'JetBrains Mono', 'SF Mono', monospace;
font-size: 9px;
color: var(--gold);
letter-spacing: 0.2em;
}
.form-rule {
width: 100%;
height: 2px;
background: var(--warm-border);
margin-top: 6px;
position: relative;
}
.form-rule::after {
content: '';
position: absolute;
left: 0;
top: 0;
width: 32px;
height: 2px;
background: var(--gold);
}
/* ═══ Form Labels ═══ */
.form-label {
font-family: 'ZCOOL XiaoWei', STSong, serif !important;
font-size: 14px !important;
color: var(--ink) !important;
letter-spacing: 0.04em;
font-weight: 500 !important;
}
.form-label-hint {
font-family: 'Noto Serif SC', STSong, serif;
font-size: 11px;
color: var(--warm-gray);
letter-spacing: 0.03em;
font-weight: 400;
margin-left: 6px;
}
/* ═══ Form Spacing ═══ */
.editorial-form .el-form-item {
margin-bottom: 20px;
}
/* ═══ Quick Create Button ═══ */
.quick-create-btn {
display: inline-flex;
align-items: center;
gap: 6px;
background: none;
border: 1px dashed var(--gold);
padding: 8px 14px;
color: var(--gold-dark);
font-family: 'Noto Serif SC', STSong, serif;
font-size: 13px;
cursor: pointer;
transition: all 0.2s;
letter-spacing: 0.03em;
}
.quick-create-btn:hover {
border-color: var(--vermilion);
color: var(--vermilion);
background: rgba(184,71,46,0.03);
}
/* ═══ Photo Area ═══ */
.photo-area {
display: flex;
flex-wrap: wrap;
gap: 10px;
align-items: center;
}
.photo-item {
display: flex;
align-items: center;
gap: 8px;
background: var(--paper);
border: 1px solid var(--warm-border);
padding: 6px 10px;
}
.photo-thumb {
width: 48px;
height: 48px;
object-fit: cover;
flex-shrink: 0;
border: 2px solid var(--warm-border);
}
.photo-label {
font-family: 'JetBrains Mono', 'SF Mono', monospace;
font-size: 11px;
color: var(--warm-gray);
letter-spacing: 0.03em;
}
.photo-remove-btn {
background: none;
border: none;
color: var(--vermilion);
cursor: pointer;
padding: 2px;
display: flex;
opacity: 0.6;
transition: opacity 0.2s;
}
.photo-remove-btn:hover { opacity: 1; }
.upload-btn { cursor: pointer; position: relative; display: inline-block; }
.upload-btn .file-input-hidden {
position: absolute; left: 0; top: 0; width: 100%; height: 100%;
.upload-btn__inner {
display: flex;
align-items: center;
gap: 6px;
padding: 12px 18px;
border: 1px dashed var(--warm-border);
color: var(--warm-gray);
font-family: 'Noto Serif SC', STSong, serif;
font-size: 13px;
transition: all 0.2s;
letter-spacing: 0.03em;
}
.upload-btn__inner:hover {
border-color: var(--gold);
color: var(--gold-dark);
}
.file-input-hidden {
position: absolute; left: 0; top: 0;
width: 100%; height: 100%;
opacity: 0; cursor: pointer;
}
/* ═══ Actions ═══ */
.form-actions {
display: flex;
gap: 10px;
margin-top: 28px;
}
.submit-btn {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 16px;
background: var(--ink);
color: #fff;
border: none;
font-family: 'ZCOOL XiaoWei', STSong, serif;
font-size: 16px;
letter-spacing: 0.08em;
cursor: pointer;
transition: all 0.25s;
}
.submit-btn:hover { background: var(--ink-light); }
.submit-btn:active { transform: scale(0.98); }
.submit-btn:disabled { opacity: 0.6; cursor: not-allowed; }
.delete-btn {
display: flex;
align-items: center;
gap: 6px;
padding: 16px 20px;
background: var(--surface);
color: var(--vermilion);
border: 1px solid var(--vermilion);
font-family: 'Noto Serif SC', STSong, serif;
font-size: 14px;
letter-spacing: 0.04em;
cursor: pointer;
transition: all 0.25s;
}
.delete-btn:hover {
background: var(--vermilion);
color: #fff;
}
/* ═══ Loading Animation ═══ */
.btn-loading {
width: 16px; height: 16px;
border: 2px solid rgba(255,255,255,0.3);
border-top-color: #fff;
border-radius: 50%;
animation: spin 0.6s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
/* ═══ Method Chips ═══ */
.method-grid { display: flex; gap: 8px; flex-wrap: wrap; }
.method-chip {
flex: 1; min-width: 64px;
padding: 10px 8px;
background: var(--surface);
border: 1px solid var(--warm-border);
font-family: 'Noto Serif SC', STSong, serif;
font-size: 13px; letter-spacing: 0.04em;
cursor: pointer; transition: all 0.25s;
text-align: center;
}
.method-chip:hover { border-color: var(--ink); }
.method-chip--active { font-weight: 600; }
.method--上门.method-chip--active { background: #4A6741; border-color: #4A6741; color: #fff; }
.method--电话.method-chip--active { background: #5B7FA5; border-color: #5B7FA5; color: #fff; }
.method--微信.method-chip--active { background: #22c55e; border-color: #22c55e; color: #fff; }
.method--出差.method-chip--active { background: #C4934A; border-color: #C4934A; color: #fff; }
</style>
+91 -13
View File
@@ -43,30 +43,108 @@ async function handleSubmit() {
<template>
<div class="form-page">
<div class="form-header">
<el-button text @click="router.back()"> 返回</el-button>
<h3>添加工作计划</h3>
<span style="width:60px"></span>
</div>
<el-form label-position="top">
<el-form-item label="客户单位">
<header class="form-editorial-header">
<button class="form-back-btn" @click="router.back()">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="19" y1="12" x2="5" y2="12"></line>
<polyline points="12 19 5 12 12 5"></polyline>
</svg>
</button>
<div class="form-title-group">
<h2 class="form-title">添加工作计划</h2>
<span class="form-subtitle">WORK PLAN</span>
</div>
<div class="form-rule"></div>
</header>
<el-form label-position="top" class="editorial-form">
<el-form-item>
<template #label><span class="form-label">客户单位</span></template>
<el-select v-model="form.customer_id" filterable remote :remote-method="loadCustomers" placeholder="搜索客户" style="width:100%">
<el-option v-for="c in customers" :key="c.id" :label="c.name" :value="c.id" />
</el-select>
</el-form-item>
<el-form-item label="工作计划">
<el-form-item>
<template #label><span class="form-label">工作计划</span></template>
<el-input v-model="form.plan_content" type="textarea" :rows="4" placeholder="描述下周工作计划..." />
</el-form-item>
<el-form-item label="计划拜访时间">
<el-form-item>
<template #label><span class="form-label">计划拜访时间</span></template>
<el-date-picker v-model="form.plan_date" type="date" style="width:100%" />
</el-form-item>
<el-button type="primary" size="large" :loading="submitLoading" @click="handleSubmit" style="width:100%">提交</el-button>
<button class="submit-btn" :disabled="submitLoading" @click="handleSubmit">
<span v-if="submitLoading" class="btn-loading"></span>
提交计划
</button>
</el-form>
</div>
</template>
<style scoped>
.form-page { max-width: 500px; margin: 0 auto; }
.form-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; }
.form-header h3 { margin: 0; }
.form-page { max-width: 100%; margin: 0 auto; padding-bottom: 20px; }
.form-editorial-header {
display: flex; align-items: flex-start; gap: 14px;
margin-bottom: 26px; flex-wrap: wrap;
}
.form-back-btn {
background: var(--surface); border: 1px solid var(--warm-border);
padding: 8px 10px; cursor: pointer; color: var(--warm-gray);
display: flex; align-items: center; transition: all var(--transition);
flex-shrink: 0; margin-top: 2px;
}
.form-back-btn:hover { color: var(--ink); border-color: var(--ink); }
.form-title-group { display: flex; flex-direction: column; gap: 0; flex: 1; }
.form-title {
margin: 0; font-family: 'ZCOOL XiaoWei', STSong, serif;
font-size: 24px; font-weight: 400; color: var(--ink);
letter-spacing: 0.06em; line-height: 1.3;
}
.form-subtitle {
font-family: 'JetBrains Mono', 'SF Mono', monospace;
font-size: 9px; color: var(--gold); letter-spacing: 0.2em;
}
.form-rule {
width: 100%; height: 2px; background: var(--warm-border);
margin-top: 6px; position: relative;
}
.form-rule::after {
content: ''; position: absolute; left: 0; top: 0;
width: 32px; height: 2px; background: var(--gold);
}
.form-label {
font-family: 'ZCOOL XiaoWei', STSong, serif !important;
font-size: 14px !important; color: var(--ink) !important;
letter-spacing: 0.04em; font-weight: 500 !important;
}
.editorial-form .el-form-item { margin-bottom: 20px; }
.submit-btn {
width: 100%; display: flex; align-items: center; justify-content: center; gap: 8px;
padding: 16px; background: var(--ink); color: #fff; border: none;
font-family: 'ZCOOL XiaoWei', STSong, serif;
font-size: 16px; letter-spacing: 0.08em; cursor: pointer; transition: all 0.25s;
margin-top: 24px;
}
.submit-btn:hover { background: var(--ink-light); }
.submit-btn:active { transform: scale(0.98); }
.submit-btn:disabled { opacity: 0.6; cursor: not-allowed; }
.btn-loading {
width: 16px; height: 16px;
border: 2px solid rgba(255,255,255,0.3);
border-top-color: #fff; border-radius: 50%;
animation: spin 0.6s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
</style>
+49
View File
@@ -0,0 +1,49 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{vue,js,ts,jsx,tsx}",
],
theme: {
extend: {
colors: {
ink: {
DEFAULT: '#1C3738',
light: '#2A4F50',
dark: '#0F1F20',
},
vermilion: {
DEFAULT: '#B8472E',
light: '#D46A4F',
dark: '#8B3522',
},
gold: {
DEFAULT: '#C4934A',
light: '#D4AD6E',
dark: '#A67736',
},
paper: {
DEFAULT: '#F5F0E8',
dark: '#EBE4D8',
},
warm: {
gray: '#7B7568',
border: '#E5DFD3',
},
sage: '#4A6741',
amber: '#C68B3C',
},
fontFamily: {
display: ['"ZCOOL XiaoWei"', 'STSong', 'Songti SC', '宋体', 'serif'],
body: ['"Noto Serif SC"', 'STSong', 'Songti SC', '宋体', 'serif'],
mono: ['"JetBrains Mono"', '"SF Mono"', 'Consolas', 'monospace'],
},
borderRadius: {
'magazine': '2px 12px 2px 12px',
},
},
},
plugins: [
require('@tailwindcss/typography'),
],
}
+12
View File
@@ -0,0 +1,12 @@
{
"version": 1,
"skills": {
"ui-design-guide": {
"name": "ui-design-guide",
"zip_url": "https://api.skillhub.cn/api/v1/download?slug=ui-design-guide",
"source": "community",
"version": "2.24.0",
"installedAt": "2026-06-23T00:18:23Z"
}
}
}
+331
View File
@@ -0,0 +1,331 @@
---
name: ui-design-guide
description: Use when users need visual direction, interface hierarchy, layout decisions, design specifications, or prototypes before implementing a Web or mini program UI.
version: 2.23.0
alwaysApply: false
---
## Standalone Install Note
If this environment only installed the current skill, start from the CloudBase main entry and use the published `cloudbase/references/...` paths for sibling skills.
- CloudBase main entry: `https://cnb.cool/tencent/cloud/cloudbase/cloudbase-skills/-/git/raw/main/skills/cloudbase/SKILL.md`
- Current skill raw source: `https://cnb.cool/tencent/cloud/cloudbase/cloudbase-skills/-/git/raw/main/skills/cloudbase/references/ui-design/SKILL.md`
Keep local `references/...` paths for files that ship with the current skill directory. When this file points to a sibling skill such as `auth-tool` or `web-development`, use the standalone fallback URL shown next to that reference.
## Activation Contract
### Use this first when
- The request is to decide visual direction, produce a design specification, create a prototype, or make layout, typography, color, and visual hierarchy choices for an interface.
- The implementation should follow a deliberate aesthetic rather than directly coding an already-approved design.
### Read before writing code if
- The response must choose typography, color, spacing, layout strategy, or other visual rules before code exists.
- The user asks for "design", "prototype", "look and feel", or "style" rather than straight implementation.
### Then also read
- Web implementation -> `../web-development/SKILL.md` (standalone fallback: `https://cnb.cool/tencent/cloud/cloudbase/cloudbase-skills/-/git/raw/main/skills/cloudbase/references/web-development/SKILL.md`)
- Mini program implementation -> `../miniprogram-development/SKILL.md` (standalone fallback: `https://cnb.cool/tencent/cloud/cloudbase/cloudbase-skills/-/git/raw/main/skills/cloudbase/references/miniprogram-development/SKILL.md`)
### Do NOT use for
- Backend-only tasks, database design, or pure API work without interface output.
- Straight implementation of an already-approved UI without new design decisions.
- Generic frontend coding requests where the visual direction is already settled.
### Common mistakes / gotchas
- Writing JSX, WXML, or CSS before outputting the design specification.
- Falling back to generic AI layouts instead of an explicit aesthetic direction.
- Jumping into implementation when the design intent is still unclear.
- Ignoring platform constraints after the visual concept is defined.
### Minimal checklist
- Read [UI Design Activation Checklist](checklist.md) before interface generation.
## When to use this skill
Use this skill for **frontend UI design and interface creation** in any project that requires:
- Creating web pages or interfaces
- Creating mini-program pages or interfaces
- Designing frontend components
- Creating prototypes or interfaces
- Handling styling and visual effects
- Any development task involving user interfaces
**Do NOT use for:**
- Backend logic or API design
- Database schema design (use data-model-creation skill)
- Pure business logic without UI components
---
## How to use this skill (for a coding agent)
1. **MANDATORY: Complete Design Specification First**
- Before writing ANY interface code, you MUST explicitly output the design specification
- This includes: Purpose Statement, Aesthetic Direction, Color Palette, Typography, Layout Strategy
- Never skip this step - it's required for quality design
2. **Follow the Design Process**
- User Experience Analysis
- Product Interface Planning
- Aesthetic Direction Determination
- High-Fidelity UI Design
- Frontend Prototype Implementation
- Realism Enhancement
3. **Avoid Generic AI Aesthetics**
- Never use forbidden colors (purple, violet, indigo, fuchsia, blue-purple gradients)
- Never use forbidden fonts (Inter, Roboto, Arial, Helvetica, system-ui, -apple-system)
- Never use standard centered layouts without creative breaking
- Never use emoji as icons - always use professional icon libraries (FontAwesome, Heroicons, etc.)
4. **Run Self-Audit Before Submitting**
- Color audit (check for forbidden colors)
- Font audit (check for forbidden fonts)
- Icon audit (verify no emoji icons, using professional icon libraries)
- Layout audit (verify asymmetry/creativity)
- Design specification compliance check
5. **Respect brand or design-system overrides when they are real constraints**
- If the project already has approved brand colors, font tokens, or a design system, treat those as higher-priority constraints
- Explicitly document which default UI-design prohibitions are being overridden and why
- Keep the override narrow: preserve the overall quality bar instead of falling back to generic AI styling
---
# UI Design Rules
You are a professional frontend engineer specializing in creating high-fidelity prototypes with distinctive aesthetic styles. Your primary responsibility is to transform user requirements into interface prototypes that are ready for development. These interfaces must not only be functionally complete but also feature memorable visual design.
## Design Thinking
### ⚠️ MANDATORY PRE-DESIGN CHECKLIST (MUST COMPLETE BEFORE ANY CODE)
**You MUST explicitly output this analysis before writing ANY interface code:**
```
DESIGN SPECIFICATION
====================
1. Purpose Statement: [2-3 sentences about problem/users/context]
2. Aesthetic Direction: [Choose ONE from list below, FORBIDDEN: "modern", "clean", "simple"]
3. Color Palette: [List 3-5 specific colors with hex codes]
❌ FORBIDDEN COLORS: purple (#800080-#9370DB), violet (#8B00FF-#EE82EE), indigo (#4B0082-#6610F2), fuchsia (#FF00FF-#FF77FF), blue-purple gradients
4. Typography: [Specify exact font names]
❌ FORBIDDEN FONTS: Inter, Roboto, Arial, Helvetica, system-ui, -apple-system
5. Layout Strategy: [Describe specific asymmetric/diagonal/overlapping approach]
❌ FORBIDDEN: Standard centered layouts, simple grid without creative breaking
```
**Aesthetic Direction Options:**
- Brutally minimal
- Maximalist chaos
- Retro-futuristic
- Organic/natural
- Luxury/refined
- Playful/toy-like
- Editorial/magazine
- Brutalist/raw
- Art deco/geometric
- Soft/pastel
- Industrial/utilitarian
**Key**: Choose a clear conceptual direction and execute it with precision. Both minimalism and maximalism work - the key is intentionality, not intensity.
### Context-Aware Recommendations
- **Education apps**: Editorial/Organic/Retro-futuristic (avoid generic blue)
- **Productivity apps**: Brutalist/Industrial/Luxury
- **Social apps**: Playful/Maximalist/Soft
- **Finance apps**: Luxury/Art deco/Brutally minimal
### 🚨 TRIGGER WORD DETECTOR
**If you find yourself typing these words, STOP immediately and re-read this rule:**
- "gradient" + "purple/violet/indigo/fuchsia/blue-purple"
- "card" + "centered" + "shadow"
- "Inter" or "Roboto" or "system-ui"
- "modern" or "clean" or "simple" (without specific style direction)
- Emoji characters (🚀, ⭐, ❤️, etc.) as icons
**Action**: Go back to Design Specification → Choose alternative aesthetic → Proceed
## Design Process
1. **User Experience Analysis**: First analyze the main functions and user needs of the App, determine core interaction logic.
2. **Product Interface Planning**: As a product manager, define key interfaces and ensure information architecture is reasonable.
3. **Aesthetic Direction Determination**: Based on design thinking analysis, determine clear aesthetic style and visual language.
4. **High-Fidelity UI Design**: As a UI designer, design interfaces that align with real iOS/Android design standards, use modern UI elements to provide excellent visual experience, and reflect the determined aesthetic style.
5. **Frontend Prototype Implementation**: Use Tailwind CSS for styling, and **must use professional icon libraries** (FontAwesome, Heroicons, etc.) - **never use emoji as icons**. Split code files and maintain clear structure.
6. **Realism Enhancement**:
- Use real UI images instead of placeholder images (can be selected from Unsplash, Pexels, Apple official UI resources)
- If video materials are needed, can use Vimeo website for video resources
## Frontend Aesthetics Guidelines
### Typography
- **Avoid Generic Fonts**: Do not use overly common fonts like Arial, Inter, Roboto, system fonts
- **Choose Distinctive Fonts**: Select beautiful, unique, and interesting fonts, for example:
- Choose distinctive display fonts paired with refined body fonts
- Consider using distinctive font combinations to elevate the interface's aesthetic level
- Font selection should align with the overall aesthetic direction
### Color & Theme
- **Unified Aesthetics**: Use CSS variables for consistency
- **Dominant Colors with Accents**: Using dominant colors with sharp accents is more effective than evenly-distributed color schemes
- **Theme Consistency**: Choose dark or light themes based on aesthetic direction, ensure color choices match the overall style
- **Brand Escape Hatch**: If a product already mandates a brand palette or typography system, you may use those tokens, but call out the override explicitly in the design specification
### Motion Design
- **Animation Strategy**: Use animations for effects and micro-interactions
- **Technology Choice**: Prioritize CSS-only solutions for HTML, React projects can use Motion library
- **High-Impact Moments**: Focus on high-impact moments. One well-orchestrated page load animation (using animation-delay for staggered reveals) creates more delight than scattered micro-interactions
- **Interactive Surprises**: Use scroll-triggering and hover states to create surprises
### Icons
- **❌ FORBIDDEN: Emoji Icons**: Never use emoji characters as icons (🚀, ⭐, ❤️, etc.)
- **✅ REQUIRED: Professional Icon Libraries**: Must use professional icon libraries such as:
- FontAwesome (recommended for most projects)
- Heroicons (for Tailwind CSS projects)
- Material Icons
- Feather Icons
- Lucide Icons
- **Icon Consistency**: Use icons from a single library throughout the project for visual consistency
- **Icon Styling**: Icons should match the overall aesthetic direction and color palette
### Spatial Composition
- **Break Conventions**: Use unexpected layouts, asymmetry, overlap, diagonal flow
- **Break the Grid**: Use grid-breaking elements
- **Negative Space Control**: Either use generous negative space or control density
### Backgrounds & Visual Details
- **Atmosphere Creation**: Create atmosphere and depth rather than defaulting to solid colors
- **Contextual Effects**: Add contextual effects and textures that match the overall aesthetic
- **Creative Forms**: Apply creative forms, such as:
- Gradient meshes
- Noise textures
- Geometric patterns
- Layered transparencies
- Dramatic shadows
- Decorative borders
- Custom cursors
- Grain overlays
### Avoid Generic AI Aesthetics
**Strictly Prohibit** the following generic AI-generated aesthetics:
- Overused font families (Inter, Roboto, Arial, system fonts)
- Cliched color schemes (particularly purple gradients on white backgrounds)
- Predictable layouts and component patterns
- Cookie-cutter design that lacks context-specific character
- **Emoji icons**: Never use emoji characters (🚀, ⭐, ❤️, etc.) as icons - always use professional icon libraries
### ❌ ANTI-PATTERNS (Code Examples to NEVER Use)
```tsx
// ❌ BAD: Forbidden purple gradient
className="bg-gradient-to-r from-violet-600 to-fuchsia-600"
className="bg-gradient-to-br from-purple-500 to-indigo-600"
// ✅ GOOD: Context-specific alternatives
className="bg-gradient-to-br from-amber-50 via-orange-50 to-rose-50" // Warm editorial
className="bg-gradient-to-tr from-emerald-900 to-teal-700" // Dark organic
className="bg-[#FF6B35] to-[#F7931E]" // Bold retro-futuristic
// ❌ BAD: Generic centered card layout
<div className="flex items-center justify-center min-h-screen">
<div className="bg-white rounded-lg shadow-lg p-8">
// ✅ GOOD: Asymmetric layout with creative positioning
<div className="grid grid-cols-12 min-h-screen">
<div className="col-span-7 col-start-2 pt-24">
// ❌ BAD: System fonts
font-family: 'Inter', system-ui, sans-serif
font-family: 'Roboto', -apple-system, sans-serif
// ✅ GOOD: Distinctive fonts
font-family: 'Playfair Display', serif // Editorial
font-family: 'Space Mono', monospace // Brutalist
font-family: 'DM Serif Display', serif // Luxury
// ❌ BAD: Emoji icons
<span>🚀</span>
<button> Favorite</button>
// ✅ GOOD: Professional icon libraries
<i className="fas fa-rocket"></i> // FontAwesome
<svg className="w-5 h-5">...</svg> // Heroicons
```
### Creative Implementation Principles
- **Creative Interpretation**: Interpret requirements creatively, make unexpected choices, make designs feel genuinely designed for the context
- **Avoid Repetition**: Each design should be different, vary between generations:
- Light and dark themes
- Different fonts
- Different aesthetic styles
- **Avoid Convergence**: Never converge on common choices (e.g., Space Grotesk)
- **Complexity Matching**: Match implementation complexity to aesthetic vision:
- Maximalist designs need elaborate code with extensive animations and effects
- Minimalist or refined designs need restraint, precision, and careful attention to spacing, typography, and subtle details
- Elegance comes from executing the vision well
## Design Constraints
If not specifically required, provide at most 4 pages. Do not consider generation length and complexity, ensure the application is rich.
## Implementation Requirements
All interface prototypes must:
- **Production-Grade Quality**: Functionally complete and ready for development
- **Visual Impact**: Visually striking and memorable
- **Aesthetic Consistency**: Have a clear aesthetic point-of-view, cohesive and unified
- **Meticulously Refined**: Every detail is carefully polished
### 🔍 SELF-AUDIT CHECKLIST (Before Submitting Code)
**Run these checks on your generated code:**
1. **Color Audit**:
```bash
# Search for forbidden colors in your code
grep -iE "(violet|purple|indigo|fuchsia)" [your-file]
# If found → VIOLATION → Choose alternative from Design Specification
```
2. **Font Audit**:
```bash
# Search for forbidden fonts
grep -iE "(Inter|Roboto|system-ui|Arial|-apple-system)" [your-file]
# If found → VIOLATION → Use distinctive font from Design Specification
```
3. **Icon Audit**:
```bash
# Search for emoji usage (common emoji patterns)
grep -iE "(🚀|⭐|❤️|👍|🔥|💡|🎉|✨)" [your-file]
# If found → VIOLATION → Replace with FontAwesome or other professional icon library
# Verify icon library is properly imported and used
```
4. **Layout Audit**:
- Does the layout use asymmetry/diagonal/overlap? (Required: YES)
- Is there creative grid-breaking? (Required: YES)
- Are elements only centered with symmetric spacing? (Allowed: NO)
5. **Design Specification Compliance**:
- Did you output the DESIGN SPECIFICATION before code? (Required: YES)
- Does the code match the aesthetic direction you declared? (Required: YES)
**If any audit fails → Re-design with correct approach**
Remember: You are capable of extraordinary creative work. Don't hold back, show what can truly be created when thinking outside the box and committing fully to a distinctive vision.
+6
View File
@@ -0,0 +1,6 @@
{
"ownerId": "544116",
"slug": "ui-design-guide",
"version": "2.24.0",
"publishedAt": 1781845782468
}
+23
View File
@@ -0,0 +1,23 @@
# UI Design Activation Checklist
Use this checklist before generating any page, component, or visual interface.
## Required checks
1. Output the design specification first.
2. Choose a concrete aesthetic direction rather than generic adjectives.
3. Define a color palette and typography before writing markup or styles.
4. Confirm the target platform: Web or mini program.
5. Read the platform implementation skill after the design spec is fixed.
## Common failure patterns
- Starting with JSX, WXML, or CSS before design intent is stated.
- Falling back to generic AI visual patterns.
- Missing platform-specific layout or asset constraints.
## Done criteria
- The design spec is visible in the response.
- Aesthetic direction, palette, and typography are explicit.
- The next implementation skill is known before UI code starts.