TTeamDesk 学习手册

src/features/teams/team-workspace.tsx

此代码来自构建时的仓库快照。修改源码后重新构建即可更新。

tsx
'use client';
import { createContext, useContext, useState } from 'react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { useQueryState, parseAsString } from 'nuqs';
import { DraftProvider } from '@/features/tickets/draft-provider';
import { draftStorageKey } from '@/stores/drafts';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import {
  Alert,
  App,
  Button,
  Form,
  Input,
  Modal,
  Select,
  Space,
  Spin,
  Tag,
} from 'antd';
import { authClient } from '@/lib/auth-client';
import { AuthForm } from '@/features/auth/auth-form';
import { useTRPC } from '@/trpc/client';
import { roleLabels, type MemberRole } from '@/shared/team';

type CurrentTeam = {
  id: string;
  name: string;
  role: MemberRole;
  userId: string;
};
const TeamContext = createContext<CurrentTeam | null>(null);
export function useTeam() {
  const value = useContext(TeamContext);
  if (!value) throw new Error('必须在团队工作区中使用');
  return value;
}

export function TeamWorkspace({ children }: { children: React.ReactNode }) {
  const session = authClient.useSession();
  const trpc = useTRPC();
  const queryClient = useQueryClient();
  const { message } = App.useApp();
  const router = useRouter();
  const [selected] = useQueryState('team', parseAsString);
  const setSelected = (id: string) => router.push(`/?team=${id}`);
  const [open, setOpen] = useState(false);
  const [leaving, setLeaving] = useState(false);
  const [form] = Form.useForm<{ name: string }>();
  const teams = useQuery(
    trpc.team.list.queryOptions(undefined, {
      enabled: !!session.data,
      staleTime: 0,
    }),
  );
  const current = selected
    ? teams.data?.find((team) => team.id === selected)
    : teams.data?.[0];
  const create = useMutation(
    trpc.team.create.mutationOptions({
      async onSuccess(team) {
        setSelected(team.id);
        await queryClient.invalidateQueries({
          queryKey: trpc.team.list.queryKey(),
        });
        setOpen(false);
        form.resetFields();
        void message.success('团队已创建,你是首位管理员');
      },
      onError(error) {
        void message.error(error.message);
      },
    }),
  );
  async function signOut() {
    setLeaving(true);
    try {
      await queryClient.cancelQueries();
      const result = await authClient.signOut();
      if (result.error) throw new Error('退出失败');
      queryClient.clear();
      // 草稿只在当前浏览器会话保留;主动退出时一并清除该账号的本地副本。
      if (session.data)
        sessionStorage.removeItem(draftStorageKey(session.data.user.id));
      // 退出后完整刷新,清除已渲染的私有内容及上一账号的缓存。
      // eslint-disable-next-line @next/next/no-location-assign-relative-destination
      window.location.assign('/');
    } catch {
      setLeaving(false);
      void message.error('退出失败,请重试');
    }
  }
  if (session.isPending || leaving)
    return (
      <div className="p-20 text-center">
        <Spin description="正在验证会话…" fullscreen />
      </div>
    );
  if (!session.data)
    return (
      <>
        <AuthForm />
        {session.error && (
          <Alert type="error" title="会话检查失败,请确认服务配置后刷新" />
        )}
      </>
    );
  return (
    <>
      <div className="flex flex-wrap items-center justify-between gap-3 border-b border-slate-200 bg-white px-6 py-3">
        <Space wrap>
          <span>{session.data.user.name}</span>
          <Select
            aria-label="当前团队"
            className="min-w-44"
            placeholder="选择团队"
            value={current?.id}
            options={teams.data?.map((team) => ({
              value: team.id,
              label: team.name,
            }))}
            onChange={(id) => setSelected(id)}
          />
          <Button onClick={() => setOpen(true)}>创建团队</Button>
          {current && <Tag color="blue">{roleLabels[current.role]}</Tag>}
        </Space>
        <Space>
          <Link href={current ? `/?team=${current.id}` : '/'}>工单</Link>
          {current && <Link href={`/team?team=${current.id}`}>团队成员</Link>}
          {current && (
            <Link href={`/tasks?team=${current.id}`}>任务与提醒</Link>
          )}
          <Button onClick={() => void signOut()}>退出登录</Button>
        </Space>
      </div>
      {teams.isError ? (
        <div className="mx-auto max-w-3xl p-8">
          <Alert
            type="error"
            title="团队加载失败"
            description={teams.error.message}
            action={<Button onClick={() => void teams.refetch()}>重试</Button>}
          />
        </div>
      ) : teams.isPending ? (
        <div className="p-20 text-center">
          <Spin />
        </div>
      ) : !current ? (
        <div className="mx-auto max-w-xl p-10">
          <h1 className="mb-5 text-2xl font-semibold">欢迎加入 TeamDesk</h1>
          <p className="mb-5 text-slate-500">
            请选择有权限的团队,或创建自己的团队。加入已有团队时,请管理员使用你的注册邮箱添加成员。
          </p>
          <Button type="primary" onClick={() => setOpen(true)}>
            创建第一个团队
          </Button>
        </div>
      ) : (
        <TeamContext.Provider
          key={`${session.data.user.id}:${current.id}:${current.role}`}
          value={{ ...current, userId: session.data.user.id }}
        >
          <DraftProvider userId={session.data.user.id}>
            {children}
          </DraftProvider>
        </TeamContext.Provider>
      )}
      <Modal
        title="创建团队"
        open={open}
        footer={null}
        onCancel={() => {
          if (!create.isPending) setOpen(false);
        }}
      >
        <Form
          form={form}
          layout="vertical"
          onFinish={(values) => create.mutate(values)}
        >
          <Form.Item
            label="团队名称"
            name="name"
            rules={[
              { required: true, whitespace: true, message: '请输入团队名称' },
              { max: 80 },
            ]}
          >
            <Input maxLength={80} />
          </Form.Item>
          <Button htmlType="submit" type="primary" loading={create.isPending}>
            创建团队
          </Button>
        </Form>
      </Modal>
    </>
  );
}

搜索教程

输入关键词,搜索全部章节。

阅读设置

外观
正文字号

从一张工单出发,读懂数据如何流转。

设置与进度仅保存在当前浏览器。