feat: 项目负责人+内部任务群+portal 内部身份领取+群主链接归属

- 迁移 0012:projects.owner_userid / tasks.task_chat_id / claims.owner_partner_id
- lib/task-group-service.ts:懒建 appchat 任务群(首个内部群主领取时创建,成员 diff 补员),播报/卡片文案
- wecom-client 新增 appchat create/update/get/send 封装
- OAuth ticket 增可选 uid(v:1 兼容),内部成员领取按 wecom_userid 解析群主身份
- 外部 OAuth 领取人 upsert 打 partner_type='group_owner'
- portal 链接带 ?owner= 穿透 OAuth 往返,claims 记录归属群主
- 后台任务卡「群主链接」面板:复制链接/推送内部群主/任务群状态
- 催办与超期升级同步播报进任务群
This commit is contained in:
ABAPPLO
2026-09-03 11:30:26 +08:00
parent 5d2a17f5b5
commit bd80b2d7d8
17 changed files with 1466 additions and 39 deletions

View File

@@ -532,6 +532,7 @@ export default function Home({ currentUser }: { currentUser: AuthUser }) {
const [taskModalOpen, setTaskModalOpen] = useState(false);
const [groupPushTask, setGroupPushTask] = useState<Task | null>(null);
const [onePushTask, setOnePushTask] = useState<Task | null>(null);
const [ownerLinksTask, setOwnerLinksTask] = useState<Task | null>(null);
const [metricModal, setMetricModal] = useState<Distribution | null>(null);
const [taskForm, setTaskForm] = useState({
taskType: "content_publish" as "content_publish" | "screenshot_collect",
@@ -1175,6 +1176,7 @@ export default function Home({ currentUser }: { currentUser: AuthUser }) {
onCopyShare={copyTaskShareLink}
onPushToGroups={(task) => setGroupPushTask(task)}
onOneClickPush={(task) => setOnePushTask(task)}
onOwnerLinks={(task) => setOwnerLinksTask(task)}
onRelease={async (distribution) =>
runAction(
{
@@ -1515,6 +1517,14 @@ export default function Home({ currentUser }: { currentUser: AuthUser }) {
/>
)}
{ownerLinksTask && (
<OwnerLinksModal
task={ownerLinksTask}
onClose={() => setOwnerLinksTask(null)}
setToast={setToast}
/>
)}
{toast && <div className="toast"><span>✓</span>{toast}</div>}
{working && <div className="working-bar" />}
</div>
@@ -2486,6 +2496,7 @@ type ProjectSummary = {
note: string;
createdAt: string;
mainGroupMemberIds: string[];
ownerUserid: string;
taskCount: number;
groupChatCount: number;
};
@@ -2541,11 +2552,12 @@ type ProjectEditorDraft = {
id: string;
name: string;
brand: string;
owner: string;
members: string;
note: string;
};
const EMPTY_PROJECT_DRAFT: ProjectEditorDraft = { id: "", name: "", brand: "", members: "", note: "" };
const EMPTY_PROJECT_DRAFT: ProjectEditorDraft = { id: "", name: "", brand: "", owner: "", members: "", note: "" };
function ProjectSection({
project,
@@ -2560,6 +2572,7 @@ function ProjectSection({
onCopyShare,
onPushToGroups,
onOneClickPush,
onOwnerLinks,
}: {
project: ProjectSummary;
tasks: Task[];
@@ -2573,6 +2586,7 @@ function ProjectSection({
onCopyShare: (task: Task) => void;
onPushToGroups: (task: Task) => void;
onOneClickPush: (task: Task) => void;
onOwnerLinks: (task: Task) => void;
}) {
const [expanded, setExpanded] = useState(false);
const [detail, setDetail] = useState<ProjectDetailData | null>(null);
@@ -2711,6 +2725,7 @@ function ProjectSection({
onCopyShare={onCopyShare}
onPushToGroups={onPushToGroups}
onOneClickPush={onOneClickPush}
onOwnerLinks={onOwnerLinks}
/>
))}
</div>
@@ -2910,6 +2925,15 @@ function ProjectEditorModal({
/>
</label>
</div>
<label>
<span>项目负责人 userid</span>
<input
value={draft.owner}
onChange={(event) => onChange({ ...draft, owner: event.target.value })}
placeholder="例如:zhangsan"
/>
<small>任务群创建时自动把负责人拉进群;请确保该成员在自建应用可见范围内。</small>
</label>
<label>
<span>项目总群成员 userid(一行一个)</span>
<textarea
@@ -3907,6 +3931,187 @@ function OverviewPage({
);
}
type OwnerLinkRow = {
partnerId: string;
name: string;
wecomUserid: string;
isInternal: boolean;
link: string;
};
type TaskGroupLinksView = {
taskName: string;
taskChatId: string;
claimUrl: string;
owners: OwnerLinkRow[];
};
function OwnerLinksModal({
task,
onClose,
setToast,
}: {
task: Task;
onClose: () => void;
setToast: (message: string) => void;
}) {
const [view, setView] = useState<TaskGroupLinksView | null>(null);
const [loading, setLoading] = useState(true);
const [pushing, setPushing] = useState(false);
const [pushResult, setPushResult] = useState<{
sent: number;
failed: number;
failList: string[];
} | null>(null);
const postAction = useCallback(
async <T,>(payload: Record<string, unknown>, fallback: string) => {
const response = await fetch("/api/action", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
const parsed = await readApiResponse<T & { error?: string }>(
response,
fallback,
);
if (!response.ok) throw new Error(parsed.error || fallback);
return parsed;
},
[],
);
useEffect(() => {
const timer = window.setTimeout(() => {
void (async () => {
try {
const parsed = await postAction<TaskGroupLinksView>(
{ action: "task_group_links", taskId: task.id },
"读取群主链接失败",
);
setView(parsed);
} catch (reason) {
setToast(
reason instanceof Error ? reason.message : "读取群主链接失败",
);
} finally {
setLoading(false);
}
})();
}, 0);
return () => window.clearTimeout(timer);
}, [postAction, task.id, setToast]);
const copyLink = async (label: string, link: string) => {
try {
await navigator.clipboard.writeText(link);
setToast(`${label}已复制`);
} catch {
window.prompt(`复制${label}`, link);
}
};
const pushLinks = async () => {
setPushing(true);
try {
const parsed = await postAction<{
sent: number;
failed: number;
failList: string[];
}>({ action: "task_group_push", taskId: task.id }, "推送群主链接失败");
setPushResult(parsed);
setToast(
parsed.failList.length > 0
? `已送达 ${parsed.sent} 位群主;失败:${parsed.failList.join("、")}`
: `已送达 ${parsed.sent} 位群主`,
);
} catch (reason) {
setToast(reason instanceof Error ? reason.message : "推送群主链接失败");
} finally {
setPushing(false);
}
};
const internalCount = view?.owners.filter((owner) => owner.isInternal).length ?? 0;
return (
<div className="modal-backdrop" role="presentation" onMouseDown={onClose}>
<div className="modal-card" onMouseDown={(event) => event.stopPropagation()}>
<div className="modal-heading">
<div>
<p className="eyebrow">任务中心 · 群主领取链接</p>
<h2>{task.name}</h2>
</div>
<button type="button" onClick={onClose} aria-label="关闭">×</button>
</div>
{loading ? (
<div className="wecom-picker-empty">正在读取群主列表…</div>
) : !view || view.owners.length === 0 ? (
<div className="wecom-picker-empty">
还没有登记群主,请先在「合作方管理」新建群主合作方(需填写企业微信 userid)。
</div>
) : (
<>
<div className="project-detail-block-head">
<h4>任务群状态</h4>
<small>
{view.taskChatId
? "已建群,群主领取后自动进群"
: "未建群:内部群主领取任务后自动创建(需项目已填负责人)"}
</small>
</div>
{view.claimUrl && (
<p className="project-empty-line">
通用领取链接(不带群主归属):
<button
type="button"
className="share-link-button"
onClick={() => void copyLink("通用领取链接", view.claimUrl)}
>
复制
</button>
</p>
)}
<ul className="project-member-list">
{view.owners.map((owner) => (
<li key={owner.partnerId}>
<span>
{owner.name}
{owner.isInternal ? "" : "(外部群主)"}
</span>
<button
type="button"
className="share-link-button"
onClick={() => void copyLink(`${owner.name} 的领取链接`, owner.link)}
>
复制链接
</button>
</li>
))}
</ul>
<div className="modal-actions">
<button type="button" className="ghost-button" onClick={onClose} disabled={pushing}>
关闭
</button>
<button
type="button"
className="primary-button"
onClick={() => void pushLinks()}
disabled={pushing || internalCount === 0}
>
{pushing ? "推送中…" : "推送内部群主(应用消息)"}
</button>
</div>
{pushResult && pushResult.failList.length > 0 && (
<small>推送失败:{pushResult.failList.join("、")}</small>
)}
</>
)}
</div>
</div>
);
}
function TaskRowCard({
task,
portalUrl,
@@ -3914,6 +4119,7 @@ function TaskRowCard({
onCopyShare,
onPushToGroups,
onOneClickPush,
onOwnerLinks,
}: {
task: Task;
portalUrl: string;
@@ -3921,6 +4127,7 @@ function TaskRowCard({
onCopyShare: (task: Task) => void;
onPushToGroups: (task: Task) => void;
onOneClickPush: (task: Task) => void;
onOwnerLinks: (task: Task) => void;
}) {
const progress = Math.round((task.claimed_quantity / task.quantity) * 100);
return (
@@ -3971,6 +4178,13 @@ function TaskRowCard({
>
一键推送
</button>
<button
className="share-link-button"
onClick={() => onOwnerLinks(task)}
disabled={!portalUrl || !task.share_token}
>
群主链接
</button>
{portalUrl && task.share_token && (
<a
className="round-arrow"
@@ -4000,6 +4214,7 @@ function TasksPage({
onCopyShare,
onPushToGroups,
onOneClickPush,
onOwnerLinks,
onRelease,
}: {
tasks: Task[];
@@ -4014,6 +4229,7 @@ function TasksPage({
onCopyShare: (task: Task) => void;
onPushToGroups: (task: Task) => void;
onOneClickPush: (task: Task) => void;
onOwnerLinks: (task: Task) => void;
onRelease: (distribution: Distribution) => Promise<boolean>;
}) {
const [selectedTaskId, setSelectedTaskId] = useState<string | null>(null);
@@ -4057,6 +4273,7 @@ function TasksPage({
id: project?.id ?? "",
name: project?.name ?? "",
brand: project?.brand ?? "",
owner: project?.ownerUserid ?? "",
members: (project?.mainGroupMemberIds ?? []).join("\n"),
note: project?.note ?? "",
});
@@ -4076,6 +4293,7 @@ function TasksPage({
id: editorDraft.id,
name: editorDraft.name,
brand: editorDraft.brand,
ownerUserid: editorDraft.owner,
mainGroupMemberIds: editorDraft.members,
note: editorDraft.note,
},
@@ -4182,6 +4400,7 @@ function TasksPage({
onCopyShare={onCopyShare}
onPushToGroups={onPushToGroups}
onOneClickPush={onOneClickPush}
onOwnerLinks={onOwnerLinks}
/>
))
)}
@@ -4202,6 +4421,7 @@ function TasksPage({
onCopyShare={onCopyShare}
onPushToGroups={onPushToGroups}
onOneClickPush={onOneClickPush}
onOwnerLinks={onOwnerLinks}
/>
))}
</div>
@@ -4223,6 +4443,7 @@ function TasksPage({
onCopyShare={onCopyShare}
onPushToGroups={onPushToGroups}
onOneClickPush={onOneClickPush}
onOwnerLinks={onOwnerLinks}
/>
))}
</div>