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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user