feat: 项目层级树+合作方绑定KOC资源+客户群全量可选+精简编辑表单,移除复盘报表
- 项目支持父子层级:树形折叠展示、详情面板内联管理总群成员、推送聚合子项目任务、环检测与子项目删除守卫 - 外部KOC合作方绑定 KOC 资源账号(唯一占用、换绑/解绑、资源页一键设为合作方),列表直显联系方式 - 绑定客户群改为全量外部群缓存(含各群主),同步按钮强刷;member_count 缺失以 member_list 兜底 - 任务/项目编辑模式隐藏不可变字段(平台/内容类型/品牌/总群成员),详情面板维护成员时保留 parent_id - 删除复盘报表整栏(导航/页面/task_report_list/导出路由/CSV lib 及测试)
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -10,7 +10,6 @@ import {
|
||||
ensureSchema,
|
||||
getDashboardData,
|
||||
getRawDb,
|
||||
getTaskReportData,
|
||||
uid,
|
||||
} from "../../../lib/mvp-db";
|
||||
import {
|
||||
@@ -61,7 +60,6 @@ import {
|
||||
} from "../../../lib/wecom-group-push-service";
|
||||
import {
|
||||
bindTaskCustomerChat,
|
||||
listOwnerCustomerChats,
|
||||
unbindTaskCustomerChat,
|
||||
} from "../../../lib/task-customer-chat-service";
|
||||
import { isManagerRequest, managerForbidden } from "../../../lib/user-auth";
|
||||
@@ -694,6 +692,89 @@ export async function POST(request: Request) {
|
||||
wecomExternalUserId: externalId || null,
|
||||
wecomName,
|
||||
});
|
||||
} else if (body.action === "partner_bind_account") {
|
||||
const forbidden = managerGate();
|
||||
if (forbidden) return forbidden;
|
||||
const partnerId = String(body.partnerId ?? "").trim();
|
||||
const accountId = String(body.accountId ?? "").trim();
|
||||
if (!partnerId) {
|
||||
return Response.json({ error: "缺少 partnerId" }, { status: 400 });
|
||||
}
|
||||
const partner = await db
|
||||
.prepare("SELECT id, name, partner_type, account_id FROM partners WHERE id = ?")
|
||||
.bind(partnerId)
|
||||
.first<{ id: string; name: string; partner_type: string | null; account_id: string | null }>();
|
||||
if (!partner) return Response.json({ error: "合作方不存在" }, { status: 404 });
|
||||
if ((partner.partner_type ?? "external_koc") !== "external_koc") {
|
||||
return Response.json({ error: "群主合作方不绑定 KOC 资源" }, { status: 400 });
|
||||
}
|
||||
if (!accountId) {
|
||||
await db
|
||||
.prepare("UPDATE partners SET account_id = '' WHERE id = ?")
|
||||
.bind(partnerId)
|
||||
.run();
|
||||
return Response.json({ partnerId, accountId: "" });
|
||||
}
|
||||
const account = await db
|
||||
.prepare("SELECT id, nickname FROM accounts WHERE id = ?")
|
||||
.bind(accountId)
|
||||
.first<{ id: string; nickname: string }>();
|
||||
if (!account) return Response.json({ error: "KOC 资源不存在" }, { status: 404 });
|
||||
const holder = await db
|
||||
.prepare(
|
||||
"SELECT id, name FROM partners WHERE account_id = ? AND id != ? LIMIT 1",
|
||||
)
|
||||
.bind(accountId, partnerId)
|
||||
.first<{ id: string; name: string }>();
|
||||
if (holder) {
|
||||
return Response.json(
|
||||
{ error: `该 KOC 资源已绑定合作方「${holder.name}」,请先在对方条目上解绑` },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
await db
|
||||
.prepare("UPDATE partners SET account_id = ? WHERE id = ?")
|
||||
.bind(accountId, partnerId)
|
||||
.run();
|
||||
return Response.json({ partnerId, accountId, nickname: account.nickname });
|
||||
} else if (body.action === "partner_create_from_account") {
|
||||
const forbidden = managerGate();
|
||||
if (forbidden) return forbidden;
|
||||
const accountId = String(body.accountId ?? "").trim();
|
||||
if (!accountId) {
|
||||
return Response.json({ error: "缺少 accountId" }, { status: 400 });
|
||||
}
|
||||
const account = await db
|
||||
.prepare("SELECT id, nickname, current_contact FROM accounts WHERE id = ?")
|
||||
.bind(accountId)
|
||||
.first<{ id: string; nickname: string; current_contact: string }>();
|
||||
if (!account) return Response.json({ error: "KOC 资源不存在" }, { status: 404 });
|
||||
const holder = await db
|
||||
.prepare("SELECT id, name FROM partners WHERE account_id = ? LIMIT 1")
|
||||
.bind(accountId)
|
||||
.first<{ id: string; name: string }>();
|
||||
if (holder) {
|
||||
return Response.json(
|
||||
{ error: `该 KOC 已是合作方「${holder.name}」,无需重复创建` },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
const partnerId = `partner-koc-${crypto.randomUUID().slice(0, 12)}`;
|
||||
await db
|
||||
.prepare(
|
||||
`INSERT INTO partners
|
||||
(id, name, wecom_name, owner, claimed_total, completed_total,
|
||||
partner_type, wecom_userid, account_id, created_at)
|
||||
VALUES (?, ?, '', '运营组', 0, 0, 'external_koc', '', ?, ?)`,
|
||||
)
|
||||
.bind(partnerId, account.nickname, accountId, new Date().toISOString())
|
||||
.run();
|
||||
return Response.json({
|
||||
partnerId,
|
||||
accountId,
|
||||
nickname: account.nickname,
|
||||
contact: account.current_contact ?? "",
|
||||
});
|
||||
} else if (body.action === "send_test_wecom") {
|
||||
const forbidden = managerGate();
|
||||
if (forbidden) return forbidden;
|
||||
@@ -858,6 +939,7 @@ export async function POST(request: Request) {
|
||||
mainGroupMemberIds: body.mainGroupMemberIds,
|
||||
ownerUserid: nextOwnerUserid,
|
||||
note: body.note,
|
||||
parentProjectId: body.parentProjectId,
|
||||
},
|
||||
resolveWecomConfig(env as unknown as WecomBindings),
|
||||
);
|
||||
@@ -970,10 +1052,6 @@ export async function POST(request: Request) {
|
||||
appFailedCount: appFailed.length,
|
||||
hint: "任务群消息已直接发送至任务内部群(负责人与领取人即时可见)",
|
||||
});
|
||||
} else if (body.action === "task_report_list") {
|
||||
return Response.json({
|
||||
reports: await getTaskReportData(db, ownerScopeUserid(scope)),
|
||||
});
|
||||
} else if (body.action === "refresh_account_fields") {
|
||||
const forbidden = managerGate();
|
||||
if (forbidden) return forbidden;
|
||||
@@ -1042,28 +1120,24 @@ export async function POST(request: Request) {
|
||||
const forbidden = await taskOwnerGate(taskId, { strict: true });
|
||||
if (forbidden) return forbidden;
|
||||
const task = await db
|
||||
.prepare(
|
||||
"SELECT id, owner_userid, customer_chat_id FROM tasks WHERE id = ?",
|
||||
)
|
||||
.prepare("SELECT id, customer_chat_id FROM tasks WHERE id = ?")
|
||||
.bind(taskId)
|
||||
.first<{ id: string; owner_userid: string; customer_chat_id: string }>();
|
||||
.first<{ id: string; customer_chat_id: string }>();
|
||||
if (!task) return Response.json({ error: "任务不存在" }, { status: 404 });
|
||||
const ownerUserid = (
|
||||
scope.kind === "full"
|
||||
? String(task.owner_userid ?? "")
|
||||
: scope.wecomUserId
|
||||
).trim();
|
||||
if (!ownerUserid) {
|
||||
return Response.json(
|
||||
{ error: "请先设置任务负责人,再绑定客户群" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
const wecomConfig = resolveWecomConfig(env as unknown as WecomBindings);
|
||||
const chats = await listOwnerCustomerChats(wecomConfig, ownerUserid);
|
||||
// 列表来自全量群缓存(所有群主的外部客户群),缓存为空或要求刷新时全量同步
|
||||
let rows = body.sync === true ? [] : await listGroupChatRows(db);
|
||||
if (rows.length === 0) {
|
||||
rows = await syncGroupChats(db, wecomConfig);
|
||||
}
|
||||
return Response.json({
|
||||
ownerUserid,
|
||||
chats,
|
||||
chats: rows.map((row) => ({
|
||||
chatId: row.chat_id,
|
||||
name: row.name,
|
||||
ownerUserId: row.owner_user_id,
|
||||
memberCount: row.member_count,
|
||||
status: row.status,
|
||||
})),
|
||||
boundChatId: String(task.customer_chat_id ?? ""),
|
||||
});
|
||||
} else if (body.action === "task_bind_customer_chat") {
|
||||
@@ -1071,24 +1145,11 @@ export async function POST(request: Request) {
|
||||
const forbidden = await taskOwnerGate(taskId, { strict: true });
|
||||
if (forbidden) return forbidden;
|
||||
const task = await db
|
||||
.prepare(
|
||||
"SELECT id, owner_userid, customer_chat_id FROM tasks WHERE id = ?",
|
||||
)
|
||||
.prepare("SELECT id, customer_chat_id FROM tasks WHERE id = ?")
|
||||
.bind(taskId)
|
||||
.first<{ id: string; owner_userid: string; customer_chat_id: string }>();
|
||||
.first<{ id: string; customer_chat_id: string }>();
|
||||
if (!task) return Response.json({ error: "任务不存在" }, { status: 404 });
|
||||
const ownerUserid = (
|
||||
scope.kind === "full"
|
||||
? String(task.owner_userid ?? "")
|
||||
: scope.wecomUserId
|
||||
).trim();
|
||||
const chatId = String(body.chatId ?? "").trim();
|
||||
if (chatId && !ownerUserid) {
|
||||
return Response.json(
|
||||
{ error: "请先设置任务负责人,再绑定客户群" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
const wecomConfig = resolveWecomConfig(env as unknown as WecomBindings);
|
||||
if (chatId) {
|
||||
const chatName = String(body.chatName ?? "").trim();
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
import { adminForbidden, isAdminRequest } from "../../../lib/admin-auth";
|
||||
import { buildTaskReportCsv } from "../../../lib/task-report-csv";
|
||||
import {
|
||||
ensureSchema,
|
||||
getRawDb,
|
||||
getTaskReportData,
|
||||
} from "../../../lib/mvp-db";
|
||||
import { getOwnerScope, ownerScopeUserid } from "../../../lib/owner-scope";
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
if (!(await isAdminRequest(request))) return adminForbidden();
|
||||
await ensureSchema();
|
||||
const rows = await getTaskReportData(
|
||||
getRawDb(),
|
||||
ownerScopeUserid(await getOwnerScope(request)),
|
||||
);
|
||||
const fileName = `任务复盘报表.csv`;
|
||||
return new Response(buildTaskReportCsv(rows), {
|
||||
headers: {
|
||||
"Cache-Control": "private, no-store",
|
||||
"Content-Type": "text/csv; charset=utf-8",
|
||||
"Content-Disposition": `attachment; filename="koc-task-report.csv"; filename*=UTF-8''${encodeURIComponent(fileName)}`,
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
return Response.json(
|
||||
{ error: error instanceof Error ? error.message : "导出失败" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
170
app/globals.css
170
app/globals.css
@@ -1486,7 +1486,7 @@ a {
|
||||
|
||||
.partner-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 0.6fr 1.4fr auto;
|
||||
grid-template-columns: 1fr 1fr 1.2fr 0.6fr 1.4fr auto;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
@@ -1519,6 +1519,59 @@ a {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.partner-resource-cell {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.partner-resource-cell strong {
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.partner-resource-contact {
|
||||
max-width: 100%;
|
||||
padding: 1px 7px;
|
||||
border-radius: 5px;
|
||||
background: #eef6f1;
|
||||
color: #3f7d63;
|
||||
font-size: 10px;
|
||||
font-style: normal;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.partner-resource-contact.empty {
|
||||
background: #f4f4f2;
|
||||
color: #a8afa8;
|
||||
}
|
||||
|
||||
.partner-resource-actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.partner-resource-actions .ghost-button {
|
||||
min-height: 22px;
|
||||
padding: 0 8px;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.partner-resource-actions .ghost-button.danger {
|
||||
border-color: rgb(180 84 63 / 0.35);
|
||||
color: #b4543f;
|
||||
}
|
||||
|
||||
.partner-resource-actions .ghost-button.danger:hover:not(:disabled) {
|
||||
border-color: rgb(180 84 63 / 0.65);
|
||||
}
|
||||
|
||||
.partner-external-cell input,
|
||||
.partner-external-cell code {
|
||||
width: 100%;
|
||||
@@ -3176,6 +3229,46 @@ a {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.resource-foot-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.resource-partner-button {
|
||||
padding: 3px 10px;
|
||||
border: 1px solid var(--green);
|
||||
border-radius: 999px;
|
||||
background: transparent;
|
||||
color: var(--green);
|
||||
font-size: 10px;
|
||||
font-weight: 650;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.resource-partner-button:hover:not(:disabled) {
|
||||
background: #eaf6f0;
|
||||
}
|
||||
|
||||
.resource-partner-button:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.resource-partner-bound {
|
||||
padding: 3px 10px;
|
||||
border-radius: 999px;
|
||||
background: #eef2ef;
|
||||
color: #687773;
|
||||
font-size: 10px;
|
||||
font-weight: 620;
|
||||
max-width: 140px;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.resource-empty {
|
||||
display: flex;
|
||||
min-height: 240px;
|
||||
@@ -5351,6 +5444,36 @@ label small {
|
||||
padding: 18px 20px;
|
||||
}
|
||||
|
||||
.project-tree-node.project-panel + .project-tree-node.project-panel {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.project-tree-toggle {
|
||||
flex: none;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: 1px solid #dfe6e1;
|
||||
border-radius: 6px;
|
||||
background: #f7faf8;
|
||||
color: #5b6b62;
|
||||
font-size: 11px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.project-tree-toggle:hover:not(:disabled) {
|
||||
border-color: #b8c8be;
|
||||
color: #2f3e36;
|
||||
}
|
||||
|
||||
.project-tree-toggle.leaf {
|
||||
opacity: 0.35;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.ungrouped-panel {
|
||||
padding: 18px 20px;
|
||||
}
|
||||
@@ -5438,20 +5561,59 @@ label small {
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.project-detail-block-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.project-member-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.project-member-list code {
|
||||
padding: 3px 8px;
|
||||
border-radius: 6px;
|
||||
.project-member-list li {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 3px 6px 3px 4px;
|
||||
border-radius: 7px;
|
||||
background: #eef2ef;
|
||||
}
|
||||
|
||||
.project-member-list code {
|
||||
padding: 0 4px;
|
||||
color: #556861;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.project-member-remove {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
color: #8a958f;
|
||||
background: #dfe6e2;
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.project-member-remove:hover {
|
||||
color: white;
|
||||
background: #c9503f;
|
||||
}
|
||||
|
||||
.project-member-remove:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.project-empty-line {
|
||||
color: #9aa39f;
|
||||
font-size: 11px;
|
||||
|
||||
@@ -37,14 +37,18 @@ export function TaskCustomerChatModal({
|
||||
const [selectedChatId, setSelectedChatId] = useState(task.customerChatId);
|
||||
const [bindResult, setBindResult] = useState<BindResult | null>(null);
|
||||
|
||||
const loadChats = useCallback(async () => {
|
||||
const loadChats = useCallback(async (options?: { sync?: boolean }) => {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const response = await fetch("/api/action", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ action: "task_customer_chats", taskId: task.id }),
|
||||
body: JSON.stringify({
|
||||
action: "task_customer_chats",
|
||||
taskId: task.id,
|
||||
sync: options?.sync === true,
|
||||
}),
|
||||
});
|
||||
const parsed = await readApiResponse<{
|
||||
chats?: CustomerChat[];
|
||||
@@ -129,7 +133,7 @@ export function TaskCustomerChatModal({
|
||||
<div>
|
||||
<h2>绑定客户群</h2>
|
||||
<p>
|
||||
列出任务负责人名下的企业微信客户群,绑定后生成进群活码,认领人在领取页扫码进群。
|
||||
列出全部成员名下的企业微信客户群(含各群主的外部群),绑定后生成进群活码,认领人在领取页扫码进群。
|
||||
</p>
|
||||
</div>
|
||||
<button type="button" onClick={onClose} aria-label="关闭">×</button>
|
||||
@@ -142,10 +146,10 @@ export function TaskCustomerChatModal({
|
||||
<button
|
||||
type="button"
|
||||
className="ghost-button"
|
||||
onClick={() => void loadChats()}
|
||||
onClick={() => void loadChats({ sync: true })}
|
||||
disabled={loading || saving}
|
||||
>
|
||||
{loading ? "拉取中…" : "刷新列表"}
|
||||
{loading ? "同步中…" : "同步最新群列表"}
|
||||
</button>
|
||||
</div>
|
||||
{error && <p className="form-error">{error}</p>}
|
||||
@@ -158,11 +162,11 @@ export function TaskCustomerChatModal({
|
||||
)}
|
||||
<div className="wecom-picker-list">
|
||||
{loading ? (
|
||||
<div className="wecom-picker-empty">正在拉取负责人名下客户群…</div>
|
||||
<div className="wecom-picker-empty">正在拉取客户群列表…</div>
|
||||
) : chats.length === 0 ? (
|
||||
<div className="wecom-picker-empty">
|
||||
未拉取到客户群——请任务负责人先在企微客户端创建客户群(内外部成员都可拉入),
|
||||
再点击「刷新列表」。
|
||||
未拉取到客户群——请群主先在企微客户端创建客户群(内外部成员都可拉入,且群主需在应用可见范围内),
|
||||
再点击「同步最新群列表」。
|
||||
</div>
|
||||
) : (
|
||||
chats.map((chat) => {
|
||||
@@ -174,7 +178,9 @@ export function TaskCustomerChatModal({
|
||||
</span>
|
||||
<span className="wecom-picker-meta">
|
||||
<span className="wecom-picker-name">{chat.name || chat.chatId}</span>
|
||||
<span className="wecom-picker-sub">{chat.memberCount} 人</span>
|
||||
<span className="wecom-picker-sub">
|
||||
群主 {chat.ownerUserId || "—"} · {chat.memberCount} 人
|
||||
</span>
|
||||
<code className="wecom-picker-id">{chat.chatId}</code>
|
||||
</span>
|
||||
<input
|
||||
|
||||
Reference in New Issue
Block a user