From c90dbf1f57baa6caf85875042956169c573c0d63 Mon Sep 17 00:00:00 2001 From: Ethan Liu Date: Wed, 21 Jun 2023 16:27:48 +0800 Subject: [PATCH] feat: Refine interaction details --- CHANGE_LOG.md | 7 + CHANGE_LOG.zh_CN.md | 7 + package.json | 4 +- pnpm-lock.yaml | 29 +- src/app/api/cron/cost/route.ts | 54 ++++ src/components/chatContent/codeblock.tsx | 11 +- src/components/menu/index.tsx | 380 ++++++++++++----------- src/components/menu/mobile.tsx | 364 ++++++++++++---------- src/components/premium/index.tsx | 14 + src/components/setting/index.tsx | 18 -- src/components/site/avatar.tsx | 9 +- src/components/site/copyIcon.tsx | 11 +- src/components/site/tokens.tsx | 10 +- src/components/ui/Button/index.tsx | 6 +- src/components/ui/Input/index.tsx | 13 +- src/locales/en.json | 25 +- src/locales/zh-CN.json | 25 +- vercel.json | 4 + 18 files changed, 553 insertions(+), 438 deletions(-) create mode 100644 src/app/api/cron/cost/route.ts diff --git a/CHANGE_LOG.md b/CHANGE_LOG.md index 3aa18c7..036f7af 100644 --- a/CHANGE_LOG.md +++ b/CHANGE_LOG.md @@ -15,6 +15,13 @@ - Added support for Token recharge to unlock more session quotas - Added activation guide - Guidance when there is insufficient Token for adding: Start Free Trial or Recharge Token +- Added token allocation details for Free trial and Premium +- Added daily scheduled task to calculate token consumption + +### Changed + +- Move the activation of the license key to the left menu for better visibility +- Optimize clipboard usage ## v0.6.2 diff --git a/CHANGE_LOG.zh_CN.md b/CHANGE_LOG.zh_CN.md index ef96130..768d828 100644 --- a/CHANGE_LOG.zh_CN.md +++ b/CHANGE_LOG.zh_CN.md @@ -15,6 +15,13 @@ - 新增支持 Token 充值,解锁更多会话额度 - 新增激活许可证引导 - 新增 Token 不足时的引导:开始免费试用 or 充值 Token +- 新增 Free trial 和 Premium 的 token 赠送数额说明 +- 新增定时任务每日核算 Token 消耗 + +### 调整 + +- 调整激活 license key 的位置到左侧菜单,更加醒目 +- 优化 clipboard 使用 ## v0.6.2 diff --git a/package.json b/package.json index be20e04..87e7cde 100644 --- a/package.json +++ b/package.json @@ -46,11 +46,11 @@ "framer-motion": "10.12.16", "gpt-tokens": "1.0.9", "js-tiktoken": "1.0.7", - "l-hooks": "0.4.5", + "l-hooks": "0.4.6", "math-random": "2.0.1", "next": "13.4.6", "next-auth": "4.22.1", - "next-intl": "2.14.6", + "next-intl": "2.15.0", "next-themes": "0.2.1", "nodemailer": "6.9.3", "postcss": "8.4.24", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 24c3158..1af83eb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -33,7 +33,7 @@ dependencies: specifier: 1.0.6 version: 1.0.6(@types/react-dom@18.2.6)(@types/react@18.2.13)(react-dom@18.2.0)(react@18.2.0) '@radix-ui/react-progress': - specifier: ^1.0.3 + specifier: 1.0.3 version: 1.0.3(@types/react-dom@18.2.6)(@types/react@18.2.13)(react-dom@18.2.0)(react@18.2.0) '@radix-ui/react-select': specifier: 1.2.2 @@ -99,14 +99,14 @@ dependencies: specifier: 10.12.16 version: 10.12.16(react-dom@18.2.0)(react@18.2.0) gpt-tokens: - specifier: ^1.0.9 + specifier: 1.0.9 version: 1.0.9 js-tiktoken: specifier: 1.0.7 version: 1.0.7 l-hooks: - specifier: 0.4.5 - version: 0.4.5(@types/qs@6.9.7)(qs@6.11.1)(react-dom@18.2.0)(react@18.2.0) + specifier: 0.4.6 + version: 0.4.6(@types/qs@6.9.7)(qs@6.11.1)(react-dom@18.2.0)(react@18.2.0) math-random: specifier: 2.0.1 version: 2.0.1 @@ -117,8 +117,8 @@ dependencies: specifier: 4.22.1 version: 4.22.1(next@13.4.6)(nodemailer@6.9.3)(react-dom@18.2.0)(react@18.2.0) next-intl: - specifier: 2.14.6 - version: 2.14.6(next@13.4.6)(react@18.2.0) + specifier: 2.15.0 + version: 2.15.0(next@13.4.6)(react@18.2.0) next-themes: specifier: 0.2.1 version: 0.2.1(next@13.4.6)(react-dom@18.2.0)(react@18.2.0) @@ -182,7 +182,7 @@ devDependencies: specifier: 1.0.0 version: 1.0.0 '@types/nodemailer': - specifier: ^6.4.8 + specifier: 6.4.8 version: 6.4.8 '@types/react-syntax-highlighter': specifier: 15.5.7 @@ -5630,8 +5630,8 @@ packages: engines: {node: '>=6'} dev: false - /l-hooks@0.4.5(@types/qs@6.9.7)(qs@6.11.1)(react-dom@18.2.0)(react@18.2.0): - resolution: {integrity: sha512-Cj724kAyw7GZgXcACttl22I+l9Xjn4vnmiD7GJuJJExa+Zbq1hsRMgwrVlJCpXrdknVq7YjtTt5pG2D1m37B1A==} + /l-hooks@0.4.6(@types/qs@6.9.7)(qs@6.11.1)(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-DTXNmKqb6J9SYmBnq2EnFWCJ6B4wsNXDyAk+hpVX40O64VteTiaRNVl+6Bby0PjQgeBrz3ZGQa97ipxbxTedug==} peerDependencies: '@types/qs': '>=6.9.7' qs: '>=6.11.1' @@ -5640,6 +5640,7 @@ packages: dependencies: '@babel/runtime': 7.21.0 '@types/qs': 6.9.7 + '@vercel/analytics': 1.0.1 qs: 6.11.1 react: 18.2.0 react-dom: 18.2.0(react@18.2.0) @@ -6289,8 +6290,8 @@ packages: uuid: 8.3.2 dev: false - /next-intl@2.14.6(next@13.4.6)(react@18.2.0): - resolution: {integrity: sha512-RZgQQMAUlGWmPx6gequHRCZf7NKD6ixCskyovRd1AMx0UeNqAZggbL7nFsGA8M7mZbE0twv3+4JLrPse8xbwsg==} + /next-intl@2.15.0(next@13.4.6)(react@18.2.0): + resolution: {integrity: sha512-fUPMlbPw0lZ+XynexfegNaqmj1BXOw/6KOPdyr734fRfjCKXjBzXQF0KB+TpjRi6oKtuYh0ZpD4JfCap9o0mNA==} engines: {node: '>=10'} peerDependencies: next: ^10.0.0 || ^11.0.0 || ^12.0.0 || ^13.0.0 @@ -6300,7 +6301,7 @@ packages: negotiator: 0.6.3 next: 13.4.6(@babel/core@7.21.8)(react-dom@18.2.0)(react@18.2.0) react: 18.2.0 - use-intl: 2.14.6(react@18.2.0) + use-intl: 2.15.0(react@18.2.0) dev: false /next-themes@0.2.1(next@13.4.6)(react-dom@18.2.0)(react@18.2.0): @@ -7832,8 +7833,8 @@ packages: tslib: 2.5.0 dev: false - /use-intl@2.14.6(react@18.2.0): - resolution: {integrity: sha512-ehkW7/CpJkJQUbQZvtUvB+NukmlOS2FEj5rSRBnyRvV+GsbC+CKKDFcMLecyWA12s9wnqcAbEMQMfp3m1jcwPA==} + /use-intl@2.15.0(react@18.2.0): + resolution: {integrity: sha512-pQbJS4HGtZjfYgm8UbEG5/2RV+X3BepqL+10bY/cPPE5ihJVXVl8LKKWoB0MfKv1PMYEtLM9B9BAe2W9m8GDXQ==} engines: {node: '>=10'} peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 diff --git a/src/app/api/cron/cost/route.ts b/src/app/api/cron/cost/route.ts new file mode 100644 index 0000000..eab4b90 --- /dev/null +++ b/src/app/api/cron/cost/route.ts @@ -0,0 +1,54 @@ +import { NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; +import { LResponseError } from "@/lib"; + +export async function GET() { + try { + const costs = await prisma.cost.findMany({ + where: { + createdAt: { + gte: new Date(new Date().setHours(0, 0, 0, 0)), + }, + }, + }); + + // if costs exist means already run today + if (costs.length) return LResponseError("already run today"); + + const users = await prisma.user.findMany({ + where: { + costTokens: { + gt: 0, + }, + }, + }); + + if (!users.length) return LResponseError("no users need to cost"); + + for (const user of users) { + const { id, availableTokens, costTokens, costUSD } = user; + + await prisma.cost.create({ + data: { + costTokens, + costUSD, + userId: id, + }, + }); + + await prisma.user.update({ + data: { + costTokens: 0, + costUSD: 0, + availableTokens: availableTokens - costTokens, + }, + where: { id: user.id }, + }); + } + + return NextResponse.json({ error: 0 }, { status: 200 }); + } catch (error) { + console.log("cost error"); + return LResponseError("error"); + } +} diff --git a/src/components/chatContent/codeblock.tsx b/src/components/chatContent/codeblock.tsx index f7090e9..007071b 100644 --- a/src/components/chatContent/codeblock.tsx +++ b/src/components/chatContent/codeblock.tsx @@ -14,16 +14,11 @@ interface Props { const CodeBlock: React.FC = React.memo(({ language, value }) => { const t = useTranslations("chat"); - const { copy } = useClipboard(); - const [copied, setCopied] = React.useState(false); + const { isCopied, copy } = useClipboard(); const copyToClipboard = () => { - if (copied) return; + if (isCopied) return; copy(value); - setCopied(true); - setTimeout(() => { - setCopied(false); - }, 1800); }; return ( @@ -35,7 +30,7 @@ const CodeBlock: React.FC = React.memo(({ language, value }) => { className="flex gap-0.5 items-center rounded bg-none p-1 text-xs text-white" onClick={copyToClipboard} > - {copied ? ( + {isCopied ? ( <> {t("copied")}! diff --git a/src/components/menu/index.tsx b/src/components/menu/index.tsx index 390fcb4..0da48d8 100644 --- a/src/components/menu/index.tsx +++ b/src/components/menu/index.tsx @@ -14,6 +14,7 @@ import { import { MdOutlineLightMode, MdDarkMode } from "react-icons/md"; import { HiOutlineTranslate } from "react-icons/hi"; import { RiFeedbackLine } from "react-icons/ri"; +import { BsKey } from "react-icons/bs"; import { useDateFormat } from "l-hooks"; import { v4 as uuidv4 } from "uuid"; import { cn } from "@/lib"; @@ -24,6 +25,7 @@ import type { ContextMenuOption } from "@/components/ui/ContextMenu"; import type { IDropdownItems } from "@/components/ui/Dropdown"; import Logo from "@/components/site/logo"; import Tokens from "@/components/site/tokens"; +import Activate from "@/components/premium/activate"; import MenuIcon from "./icon"; export const lans: IDropdownItems[] = [ @@ -45,15 +47,18 @@ export default function Menu() { const router = useRouter(); const pathname = usePathname(); const { theme, setTheme } = useTheme(); - const t = useTranslations("menu"); const { format } = useDateFormat(); const [, setVisible] = useSetting(); const [channel, setChannel] = useChannel(); const [nowTheme, setNowTheme] = React.useState<"dark" | "light">("light"); const [loadingChangeLang, setLoadingChangeLang] = React.useState(false); + const t = useTranslations("menu"); + const tPremium = useTranslations("premium"); + // ref const scrollRef = React.useRef(null); + const activateRef = React.useRef(null); const menuItems: ContextMenuOption[] = [ { @@ -81,6 +86,8 @@ export default function Menu() { }, 200); }; + const onActivate = () => activateRef.current?.init(); + const onChannelClear = () => { setChannel((channel) => { channel.list = initChannelList; @@ -147,86 +154,87 @@ export default function Menu() { }, [pathname]); return ( - +
- {t("feedback")} - - {!!session.data && } -
-
-
- {nowTheme === "light" ? ( - - ) : ( - - )} -
-
-
- - - +
+ + {tPremium("license-activate")}
- -
- {loadingChangeLang ? ( - - ) : ( - - )} -
+
+ {t("clear-all-conversation")}
} + onOk={onChannelClear} /> -
-
setVisible(true)} - className={cn( - "w-8 h-8 flex justify-center items-center cursor-pointer transition-colors rounded-md", - "hover:bg-gray-200/60", - "dark:hover:bg-slate-700/70" - )} - > - + + {t("feedback")} + + {!!session.data && } +
+
+
+ {nowTheme === "light" ? ( + + ) : ( + + )} +
+
+
+ + + +
+ +
+ {loadingChangeLang ? ( + + ) : ( + + )} +
+
+ } + /> +
+
setVisible(true)} + className={cn( + "w-8 h-8 flex justify-center items-center cursor-pointer transition-colors rounded-md", + "hover:bg-gray-200/60", + "dark:hover:bg-slate-700/70" + )} + > + +
-
+ + ); } diff --git a/src/components/menu/mobile.tsx b/src/components/menu/mobile.tsx index 91455a6..24a41f5 100644 --- a/src/components/menu/mobile.tsx +++ b/src/components/menu/mobile.tsx @@ -12,6 +12,7 @@ import { import { MdOutlineLightMode, MdDarkMode } from "react-icons/md"; import { HiOutlineTranslate } from "react-icons/hi"; import { RiFeedbackLine } from "react-icons/ri"; +import { BsKey } from "react-icons/bs"; import { v4 as uuidv4 } from "uuid"; import { cn } from "@/lib"; import { Button, Confirm, Drawer, Dropdown } from "@/components/ui"; @@ -25,13 +26,13 @@ import { import { lans } from "./index"; import MenuIcon from "./icon"; import Tokens from "@/components/site/tokens"; +import Activate from "@/components/premium/activate"; export default function MobileMenu() { const session = useSession(); const locale = useLocale(); const router = useRouter(); const { theme, setTheme } = useTheme(); - const t = useTranslations("menu"); const { format } = useDateFormat(); const [channel, setChannel] = useChannel(); const [mobileMenuVisible, setMobileMenuVisible] = useMobileMenu(); @@ -39,6 +40,11 @@ export default function MobileMenu() { const [nowTheme, setNowTheme] = React.useState(""); + const t = useTranslations("menu"); + const tPremium = useTranslations("premium"); + + const activateRef = React.useRef(null); + const onClose = () => setMobileMenuVisible(false); const onChannelAdd = () => { @@ -53,6 +59,8 @@ export default function MobileMenu() { onClose(); }; + const onActivate = () => activateRef.current?.init(); + const onChannelClear = () => { setChannel((channel) => { channel.list = initChannelList; @@ -102,201 +110,215 @@ export default function MobileMenu() { }, [theme]); return ( - - -
- } - width="78%" - open={mobileMenuVisible} - onClose={onClose} - > -
- -
- {channel.list.map((item) => ( -
onChannelChange(item.channel_id)} - className={cn( - "rounded-lg mb-1 cursor-pointer transition-colors overflow-hidden relative flex flex-col h-16 text-xs px-[0.5rem] gap-1 justify-center", - "hover:bg-gray-200/60 dark:hover:bg-slate-700/70", - { - "bg-sky-100 hover:bg-sky-100 dark:bg-slate-600 dark:hover:bg-slate-600": - item.channel_id === channel.activeId, - } - )} - > -
-
- - {item.channel_name || t("new-conversation")} -
-
- {item.chat_list.length - ? item.chat_list.at(-1)?.time - ? format( - Number(item.chat_list.at(-1)?.time), - "MM-DD HH:mm:ss" - ) - : "" - : ""} -
-
+ <> + + +
+ } + width="78%" + open={mobileMenuVisible} + onClose={onClose} + > +
+ +
+ {channel.list.map((item) => (
onChannelChange(item.channel_id)} className={cn( - "text-neutral-500/90 dark:text-neutral-500 dark:group-hover:text-neutral-400", + "rounded-lg mb-1 cursor-pointer transition-colors overflow-hidden relative flex flex-col h-16 text-xs px-[0.5rem] gap-1 justify-center", + "hover:bg-gray-200/60 dark:hover:bg-slate-700/70", { - "dark:text-neutral-400": + "bg-sky-100 hover:bg-sky-100 dark:bg-slate-600 dark:hover:bg-slate-600": item.channel_id === channel.activeId, } )} > - {item.chat_list.length} {t("messages")} -
-
e.stopPropagation()} > - + + {item.channel_name || t("new-conversation")}
- } - onOk={() => onChannelDelete(item.channel_id)} - /> -
- ))} -
-
- - {t("clear-all-conversation")} -
- } - onOk={onChannelClear} - /> - - {t("feedback")} - - {!!session.data && } -
-
-
- {nowTheme === "light" ? ( - - ) : ( - - )} -
-
-
- - - -
-
- + {item.chat_list.length + ? item.chat_list.at(-1)?.time + ? format( + Number(item.chat_list.at(-1)?.time), + "MM-DD HH:mm:ss" + ) + : "" + : ""}
+
+ {item.chat_list.length} {t("messages")} +
+ e.stopPropagation()} + > + +
+ } + onOk={() => onChannelDelete(item.channel_id)} + /> +
+ ))} + +
+
+ + {tPremium("license-activate")} +
+ + {t("clear-all-conversation")} +
} + onOk={onChannelClear} /> -
-
setSettingVisible(true)} - className={cn( - "w-8 h-8 flex justify-center items-center cursor-pointer transition-colors rounded-md", - "hover:bg-gray-200/60", - "dark:hover:bg-slate-700/70" - )} - > - + + {t("feedback")} + + {!!session.data && } +
+
+
+ {nowTheme === "light" ? ( + + ) : ( + + )} +
+
+
+ + + +
+ +
+ +
+
+ } + /> +
+
setSettingVisible(true)} + className={cn( + "w-8 h-8 flex justify-center items-center cursor-pointer transition-colors rounded-md", + "hover:bg-gray-200/60", + "dark:hover:bg-slate-700/70" + )} + > + +
- - + + + ); } diff --git a/src/components/premium/index.tsx b/src/components/premium/index.tsx index abc0bc1..9026256 100644 --- a/src/components/premium/index.tsx +++ b/src/components/premium/index.tsx @@ -130,6 +130,13 @@ const Premium: React.FC = () => { /> {t("free-4")} +
+ + {t("free-5")} +
{ /> {t("premium-6")} +
+ + {t("premium-7")} +
(null); - const activateRef = React.useRef(null); const [plat, setPlat] = React.useState("windows"); const [loading, setLoading] = React.useState(false); const t = useTranslations("setting"); - const tPremium = useTranslations("premium"); const onClose = () => setVisible(false); @@ -129,8 +126,6 @@ export default function Setting() { }); }; - const onActivate = () => activateRef.current?.init(); - React.useEffect(() => { setPlat(getPlatform()); @@ -213,18 +208,6 @@ export default function Setting() { onChange={onChangeSendMessageType} /> - -
-
{tPremium("license")}
- -
handleImport(e.target.files)} /> - ); } diff --git a/src/components/site/avatar.tsx b/src/components/site/avatar.tsx index 82d31ca..8069963 100644 --- a/src/components/site/avatar.tsx +++ b/src/components/site/avatar.tsx @@ -12,6 +12,7 @@ import { AiFillGift, AiOutlineAppstoreAdd, AiOutlineUser, + AiOutlineLogin, } from "react-icons/ai"; import { usePromptOpen, useUserInfo, usePremium } from "@/hooks"; import Dropdown, { type IDropdownItems } from "@/components/ui/Dropdown"; @@ -178,9 +179,11 @@ export default function Avatar() { ) : (
- +
) } diff --git a/src/components/site/copyIcon.tsx b/src/components/site/copyIcon.tsx index dc8d0cb..decd468 100644 --- a/src/components/site/copyIcon.tsx +++ b/src/components/site/copyIcon.tsx @@ -8,21 +8,16 @@ const CopyIcon: React.FC> = ({ className, content, }) => { - const [copySuccess, setCopySuccess] = React.useState(false); - const { copy } = useClipboard(); + const { isCopied, copy } = useClipboard(); const onCopy = () => { - if (copySuccess) return; + if (isCopied) return; copy(content); - setCopySuccess(true); - setTimeout(() => { - setCopySuccess(false); - }, 1800); }; return (
- {copySuccess ? ( + {isCopied ? ( ) : ( diff --git a/src/components/site/tokens.tsx b/src/components/site/tokens.tsx index d87dc65..5cc06fe 100644 --- a/src/components/site/tokens.tsx +++ b/src/components/site/tokens.tsx @@ -14,7 +14,7 @@ export default function Tokens({ type }: { type: "pc" | "mobile" }) { const [toggle, setToggle] = React.useState(false); const percent = React.useMemo(() => { - if (!availableTokens) return 0; + if (availableTokens <= 0) return 0; const left = ((availableTokens - costTokens) / availableTokens) * 100; @@ -24,8 +24,6 @@ export default function Tokens({ type }: { type: "pc" | "mobile" }) { const onRecharge = (e: any) => { e.stopPropagation(); setOpen(true); - // window.location.href = - // "https://ltopx.lemonsqueezy.com/checkout/buy/df7a231a-ebb4-487e-9a10-961f10e47246"; }; return ( @@ -43,7 +41,7 @@ export default function Tokens({ type }: { type: "pc" | "mobile" }) { {!toggle ? (
@@ -80,7 +78,9 @@ export default function Tokens({ type }: { type: "pc" | "mobile" }) { : availableTokens - costTokens} / - {availableTokens} + + {availableTokens < 0 ? 0 : availableTokens} +
)}
diff --git a/src/components/ui/Button/index.tsx b/src/components/ui/Button/index.tsx index 8acfa08..f4bddd8 100644 --- a/src/components/ui/Button/index.tsx +++ b/src/components/ui/Button/index.tsx @@ -8,7 +8,8 @@ type ButtonType = "default" | "primary" | "success" | "danger" | "outline"; type ButtonSize = "xs" | "sm" | "base" | "lg"; -interface ButtonProps extends React.HTMLAttributes { +interface ButtonProps + extends Omit, "type"> { // Option to fit button width to its parent width disabled?: boolean; block?: boolean; @@ -19,7 +20,7 @@ interface ButtonProps extends React.HTMLAttributes { loading?: boolean; } -const Button = React.forwardRef( +const Button = React.forwardRef( ( { children, @@ -51,6 +52,7 @@ const Button = React.forwardRef( return (