feat: 大致完成网页UI

This commit is contained in:
zhaoyingbo 2023-09-27 10:21:35 +08:00
parent c7fcd0d072
commit 56391a68f7
15 changed files with 710 additions and 41 deletions

View File

@ -21,8 +21,18 @@
## 零碎TODO
[ ] 精细化模板卡片的返回值,以及存储
[ ] 模板改成JSON格式的模板ID的话只能自己创建
[ ] 支持编辑更多设置,飞书网页就行
[ ] 飞书网页鉴权逻辑,分为内网和外网部分,内网使用米盾,支持查看全部的提醒信息等等,外网只支持创建/编辑卡片带出来的提醒,但是这里不支持鉴权,尝试改成群里的聊天仅对发送者可见
[ ] 仅对发送者可见的卡片是否可以后续修改成结果对全员显示
[ ] 提醒挂靠在卡片发送者的身上,不论网页还是卡片,谁触发提醒卡片,挂靠在谁身上
[ ] 支持快速提醒,输入数字,[1-120]分钟
[ ] 销毁创建用卡片,创建卡片添加取消按钮,点击去掉卡片可交互部分
@ -39,6 +49,8 @@
[ ] 离职人员的提醒会发一条通知,然后提醒别人认领
[x] 数据库新增图片key value以及原图保存方便查看
[x] 支持同一提醒设置多个时间
[x] 每个时间支持多时间点设置类似每周一二三每月1 3 10号

View File

@ -9,7 +9,11 @@
"settings": {
"files.autoSave": "off",
"editor.guides.bracketPairs": true,
"editor.defaultFormatter": "esbenp.prettier-vscode"
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true,
"editor.codeActionsOnSave": {
"source.fixAll.eslint": true
}
},
"extensions": [
"dbaeumer.vscode-eslint",
@ -26,5 +30,5 @@
]
}
},
"postCreateCommand": "bash -i /workspaces/view/.devcontainer/initial.bash"
"postCreateCommand": "bash -i /workspaces/egg_server/view/.devcontainer/initial.bash"
}

View File

@ -1 +1 @@
echo "alias dev=\"cd /workspaces/view && yarn dev\"" >> /home/node/.bashrc
echo "alias dev=\"cd /workspaces/egg_server/view && yarn dev\"" >> /home/node/.bashrc

View File

@ -4,6 +4,8 @@ module.exports = {
project: './tsconfig.json',
},
rules: {
'import/extensions': 'off',
'react/react-in-jsx-scope': 'off',
'no-console': 'off',
},
};

View File

@ -1,34 +0,0 @@
# Mantine Vite template
## Features
This template comes with the following features:
- [PostCSS](https://postcss.org/) with [mantine-postcss-preset](https://mantine.dev/styles/postcss-preset)
- [TypeScript](https://www.typescriptlang.org/)
- [Storybook](https://storybook.js.org/)
- [Jest](https://jestjs.io/) setup with [React Testing Library](https://testing-library.com/docs/react-testing-library/intro)
- ESLint setup with [eslint-config-mantine](https://github.com/mantinedev/eslint-config-mantine)
## npm scripts
## Build and dev scripts
- `dev` start development server
- `build` build production version of the app
- `preview` locally preview production build
### Testing scripts
- `typecheck` checks TypeScript types
- `lint` runs ESLint
- `prettier:check` checks files with Prettier
- `jest` runs jest tests
- `jest:watch` starts jest watch
- `test` runs `jest`, `prettier:check`, `lint` and `typecheck` scripts
### Other scripts
- `storybook` starts storybook dev server
- `storybook:build` build production storybook bundle to `storybook-static`
- `prettier:write` formats all files with Prettier

View File

@ -35,9 +35,11 @@
"@tiptap/starter-kit": "^2.1.11",
"dayjs": "^1.11.10",
"embla-carousel-react": "^8.0.0-rc14",
"moment": "^2.29.4",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.11.2"
"react-router-dom": "^6.11.2",
"pocketbase": "^0.16.0"
},
"devDependencies": {
"@storybook/addon-essentials": "^7.0.18",

View File

@ -1,11 +1,15 @@
import '@mantine/core/styles.css';
import '@mantine/dates/styles.css';
import '@mantine/notifications/styles.css';
import { MantineProvider } from '@mantine/core';
import { Notifications } from '@mantine/notifications';
import { Router } from './Router';
import { theme } from './theme';
export default function App() {
return (
<MantineProvider theme={theme}>
<Notifications />
<Router />
</MantineProvider>
);

View File

@ -0,0 +1,302 @@
import { Accordion, Button, Select, MultiSelect, Center, Text } from '@mantine/core';
import { DatePicker, DateTimePicker, TimeInput } from '@mantine/dates';
import { useUncontrolled } from '@mantine/hooks';
import { useState } from 'react';
import { IconCalendar, IconClock } from '@tabler/icons-react';
import moment from 'moment';
import accordionClasses from './style.module.css';
import { get202401Date, getDateFrom202401Date, getMMDDFromDate, getFullTimeStr, getTimeStr, getDateFromMMDD } from '@/utils/time';
/**
*
*
*/
interface RemindTime {
/**
*
* single: 一次性
* daily: 每天
* weekly: 每周
* monthly: 每月
* yearly: 每年
* workday: 工作日
* holiday: 节假日
*/
frequency:
| 'single'
| 'daily'
| 'weekly'
| 'monthly'
| 'yearly'
| 'workday'
| 'holiday';
/**
* 格式为HH:mm single类型时仅作展示用yyyy-MM-dd HH:mm
*/
time: string;
/**
* [1-7]frequency为weekly时有效
*/
daysOfWeek?: number[];
/**
* [1-31]frequency为monthly时有效
*/
daysOfMonth?: number[];
/**
* frequency为 yearly MM-dd
*/
dayOfYear?: string;
/**
* fuck ts
*/
[key: string]: any;
}
/**
*
*/
const emojiList = ['🍅', '🍆', '🍌', '🍎', '🥝', '🥔', '🥕', '🌽', '🌶', '🍓', '🥬', '🍑', '🥭', '🥥', '🫐', '🍇', '🍉'];
/**
*
*/
const frequencyType = [
{ label: '一次性', value: 'single' },
{ label: '每天', value: 'daily' },
{ label: '每周', value: 'weekly' },
{ label: '每月', value: 'monthly' },
{ label: '每年', value: 'yearly' },
{ label: '工作日', value: 'workday' },
{ label: '节假日', value: 'holiday' },
];
/**
*
*/
const weekdayType = [
{ label: '星期一', value: '1' },
{ label: '星期二', value: '2' },
{ label: '星期三', value: '3' },
{ label: '星期四', value: '4' },
{ label: '星期五', value: '5' },
{ label: '星期六', value: '6' },
{ label: '星期日', value: '7' },
];
/**
*
*/
const getCHSTime = (remindTime: RemindTime) => {
const errText = <Text span c="red" size="md"></Text>;
const getWeekdayCHS = (day: number) => ['', '一', '二', '三', '四', '五', '六', '日'][day];
if (remindTime.frequency === 'single') return remindTime.time;
if (remindTime.frequency === 'daily') return `每天的${remindTime.time}`;
if (remindTime.frequency === 'workday') return `工作日的${remindTime.time}`;
if (remindTime.frequency === 'holiday') return `节假日的${remindTime.time}`;
if (remindTime.frequency === 'monthly') {
return (
<span>
{remindTime.daysOfMonth?.join(',') || errText}
{remindTime.time}
</span>
);
}
if (remindTime.frequency === 'weekly') {
return (
<span>
{remindTime.daysOfWeek?.map(getWeekdayCHS)?.join(',') || errText}
{remindTime.time}
</span>
);
}
if (remindTime.frequency === 'yearly') {
return (
<span>
{remindTime.dayOfYear || errText}
{remindTime.time}
</span>
);
}
return '每周一、日的17:00';
};
const MultiTimePicker = ({ remindTimes, onChange }: {
remindTimes: RemindTime[];
onChange: (val: RemindTime[]) => void
}) => {
const [openIndex, setOpenIndex] = useState<string | null>('');
const [times, handleChange] = useUncontrolled({
value: remindTimes,
defaultValue: [],
finalValue: [],
onChange,
});
const createTime = () => {
handleChange([{ frequency: 'single', time: getFullTimeStr() }, ...times]);
setOpenIndex('0');
};
const changeTime = (idx: number, type: keyof RemindTime, val: RemindTime[keyof RemindTime]) => {
const curItem = { ...times[idx] };
let finalVal = val;
// 数据处理
if (type === 'frequency') {
// 必须选一个默认single
if (!finalVal) finalVal = 'single';
if (finalVal === 'single') {
// 单次提醒的时间需要设置为 yyyy-MM-dd HH:mm
curItem.time = getFullTimeStr();
} else {
// 其余选项的时间都是 HH:mm
curItem.time = getTimeStr();
}
// 清理其他内容
curItem.dayOfYear = '';
curItem.daysOfWeek = [];
curItem.daysOfMonth = [];
}
if (type === 'time') {
if (curItem.frequency === 'single') {
// 这里的时间是完整的时间需要用moment转一下
finalVal = getFullTimeStr(finalVal);
}
}
if (type === 'daysOfWeek') {
finalVal = finalVal.map(Number).sort((a: number, b: number) => a - b);
}
if (type === 'daysOfMonth') {
finalVal = finalVal.map(getDateFrom202401Date).sort((a: number, b: number) => a - b);
}
if (type === 'dayOfYear') {
finalVal = getMMDDFromDate(finalVal);
}
console.log('finalVal', finalVal);
// 赋值
curItem[type] = finalVal;
handleChange([
...times.slice(0, idx),
curItem,
...times.slice(idx + 1),
]);
};
const delTime = (idx: number) => handleChange([
...times.slice(0, idx),
...times.slice(idx + 1),
]);
return (
<>
<Button fullWidth variant="default" my="md" onClick={() => createTime()}></Button>
<Accordion classNames={accordionClasses} value={openIndex} onChange={setOpenIndex}>
{times.map((item, idx) => (
<Accordion.Item key={idx} value={String(idx)}>
<Accordion.Control
icon={emojiList[(times.length - idx - 1) % emojiList.length]}
>
{getCHSTime(item)}
</Accordion.Control>
<Accordion.Panel>
<Select
mb="xs"
label="重复类型"
description={['workday', 'holiday'].includes(item.frequency) && '计算含法定假日,类似周一到周五固定值请选择每周循环'}
withAsterisk
data={frequencyType}
checkIconPosition="right"
allowDeselect={false}
value={item.frequency}
onChange={(val: RemindTime['frequency']) => changeTime(idx, 'frequency', val)}
/>
{/* 单次提醒 */}
{item.frequency === 'single' && (
<DateTimePicker
label="提醒时间"
leftSection={<IconCalendar style={{ width: 18, height: 18 }} stroke={1.5} />}
withAsterisk
minDate={new Date()}
valueFormat="YYYY-MM-DD HH:mm"
value={new Date(item.time)}
dropdownType="modal"
onChange={(val: any) => changeTime(idx, 'time', val)}
/>
)}
{/* 除了单次提醒都需要设置HH:mm的时间 */}
{item.frequency !== 'single' && (
<TimeInput
my="xs"
label="提醒时间"
withAsterisk
value={item.time}
leftSection={<IconClock style={{ width: 18, height: 18 }} stroke={1.5} />}
onChange={event => changeTime(idx, 'time', event.currentTarget.value)}
/>
)}
{/* 每周循环,选择星期几提醒 */}
{item.frequency === 'weekly' && (
<MultiSelect
my="xs"
label="星期"
withAsterisk
data={weekdayType}
checkIconPosition="right"
error={item.daysOfWeek?.length === 0 ? '请至少选择一个星期' : ''}
value={item.daysOfWeek?.map(String)}
onChange={(val: string[]) => changeTime(idx, 'daysOfWeek', val)}
/>
)}
{/* 每月循环选择1-31日期 */}
{item.frequency === 'monthly' && (
<>
<Text size="sm" fw={500}></Text>
<Text size="xs" c="dimmed"></Text>
<Center>
<DatePicker
my="xs"
defaultDate={new Date(2024, 0)}
type="multiple"
size="md"
hideWeekdays
hideOutsideDates
value={item.daysOfMonth?.map(get202401Date)}
onChange={val => changeTime(idx, 'daysOfMonth', val)}
styles={{
calendarHeader: { display: 'none' },
}}
/>
</Center>
</>
)}
{/* 每年循环,选择日期 */}
{item.frequency === 'yearly' && (
<>
<Text size="sm" fw={500}></Text>
<Text size="xs" c="dimmed"></Text>
<Center>
<DatePicker
my="xs"
size="md"
value={getDateFromMMDD(item.dayOfYear || '')}
onChange={val => changeTime(idx, 'dayOfYear', val)}
minDate={moment().startOf('year').toDate()}
maxDate={moment().endOf('year').toDate()}
/>
</Center>
</>
)}
<Button fullWidth variant="light" color="red" my="md" onClick={() => delTime(idx)} id={`timePickerAnchor_${idx}`}></Button>
</Accordion.Panel>
</Accordion.Item>
))}
</Accordion>
</>
);
};
export default MultiTimePicker;

View File

@ -0,0 +1,27 @@
.root {
border-radius: var(--mantine-radius-sm);
background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6));
}
.item {
background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6));
border: rem(1px) solid transparent;
position: relative;
z-index: 0;
transition: transform 150ms ease;
&[data-active] {
transform: scale(1.03);
z-index: 1;
background-color: var(--mantine-color-body);
border-color: light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-4));
box-shadow: var(--mantine-shadow-md);
border-radius: var(--mantine-radius-md);
}
}
.chevron {
&[data-rotate] {
transform: rotate(-90deg);
}
}

View File

@ -1,6 +1,271 @@
import { Box, Button, Center, Collapse, NumberInput, SegmentedControl, Select, Switch, TextInput } from '@mantine/core';
import { isNotEmpty, useForm } from '@mantine/form';
import { useSetState } from '@mantine/hooks';
import { IconAdjustmentsCode, IconCards } from '@tabler/icons-react';
import { notifications } from '@mantine/notifications';
import { useEffect } from 'react';
import MultiTimePicker from '@/components/MultiTimePicker';
import { getImgList } from '@/server';
export function HomePage() {
// 从参数中获取订阅者信息
const searchParams = new URLSearchParams(window.location.search);
const [state, setState] = useSetState({
type: 'card',
imageKeyList: [{ label: 'loading...', value: 'loading...' }],
});
useEffect(() => {
getImgList().then((records) => {
setState({
imageKeyList: records.map(({ key, description }) => ({ label: description, value: key })),
});
}).catch(err => console.error(err));
}, []);
const form = useForm({
initialValues: {
// card
title: '',
imageKey: '',
content: '',
confirmText: '',
cancelText: '',
delayText: '',
// template
pendingTemplateId: '',
interactedTemplateId: '',
confirmedTemplateId: '',
cancelededTemplateId: '',
delayedTemplateId: '',
// time
remindTimes: [],
// subscriber
subscriberType: searchParams.get('subscriberType') ?? '',
subscriberId: searchParams.get('subscriberId') ?? '',
// others
needReply: false,
delayTime: 15,
},
validate: {
content: state.type === 'card' ? isNotEmpty('请填入卡片内容') : () => null,
subscriberType: isNotEmpty('请选择订阅者类型'),
subscriberId: isNotEmpty('请输入订阅者ID'),
remindTimes: (value) => {
// 这个地方只能使用notify提醒
if (value.length === 0) {
notifications.show({
message: '请至少选择一个用来提醒的时间! 🤥',
color: 'red',
autoClose: 2000,
});
return '请至少选择一个用来提醒的时间! 🤥';
}
return null;
},
pendingTemplateId: state.type === 'template' ? isNotEmpty('请输入模板ID') : () => null,
interactedTemplateId: (
value,
{ needReply, confirmedTemplateId, cancelededTemplateId, delayedTemplateId }
) => {
if (
needReply
&& !value && !confirmedTemplateId && !cancelededTemplateId && !delayedTemplateId
) {
return '请输入模板ID';
}
return null;
},
confirmedTemplateId: (
value,
{ needReply, interactedTemplateId }
) => {
if (needReply && !value && !interactedTemplateId) {
return '请输入模板ID';
}
return null;
},
cancelededTemplateId: (
value,
{ needReply, interactedTemplateId }
) => {
if (needReply && !value && !interactedTemplateId) {
return '请输入模板ID';
}
return null;
},
delayedTemplateId: (
value,
{ needReply, interactedTemplateId }
) => {
if (needReply && !value && !interactedTemplateId) {
return '请输入模板ID';
}
return null;
},
},
});
return (
<>
</>
<Box maw={340} mx="auto" py="lg">
<form onSubmit={form.onSubmit((values) => console.log(values))}>
<Center>
<SegmentedControl
mb="xs"
value={state.type}
onChange={(value) => setState({ type: value })}
data={[
{
value: 'card',
label: (
<Center>
<IconAdjustmentsCode size={16} />
<Box ml={10}></Box>
</Center>
),
},
{
value: 'template',
label: (
<Center>
<IconCards size={16} />
<Box ml={10}></Box>
</Center>
),
},
]}
/>
</Center>
{state.type === 'card' && (
<>
<TextInput
mb="xs"
label="卡片标题"
description="不填默认:🍳小煎蛋提醒!"
{...form.getInputProps('title')}
/>
<TextInput
mb="xs"
withAsterisk
label="卡片内容"
{...form.getInputProps('content')}
/>
<Select
mb="xs"
label="图片Key"
data={state.imageKeyList}
description="如果需要添加图片找zhaoyingbo"
checkIconPosition="right"
searchable
{...form.getInputProps('imageKey')}
/>
</>
)}
{state.type === 'template' && (
<>
<TextInput
mb="xs"
withAsterisk
label="提醒卡片模板ID"
{...form.getInputProps('pendingTemplateId')}
/>
</>
)}
<Switch
my="md"
label="重复提醒 & 结果确认"
description={`如提醒后未回复,将在 ${form.values.delayTime}min 之后重新提醒`}
{...form.getInputProps('needReply')}
/>
<Collapse in={form.values.needReply}>
<NumberInput
mb="md"
withAsterisk
label="提醒时间间隔"
description="单位:分钟"
min={1}
max={1440}
{...form.getInputProps('delayTime')}
/>
{state.type === 'card' && (
<>
<TextInput
mb="xs"
label="确认操作文字"
description="不填默认:完成"
{...form.getInputProps('confirmText')}
/>
<TextInput
mb="xs"
label="取消操作文字"
description="不填不显示交互按钮"
{...form.getInputProps('cancelText')}
/>
<TextInput
mb="xs"
label="延迟操作文字"
description="不填不显示交互按钮"
{...form.getInputProps('delayText')}
/>
</>
)}
{state.type === 'template' && (
<>
<TextInput
mb="xs"
label="交互后结果模板ID"
description="如果填了这个则不需要下边三个"
{...form.getInputProps('interactedTemplateId')}
/>
<TextInput
mb="xs"
label="确认结果模板ID"
{...form.getInputProps('confirmedTemplateId')}
/>
<TextInput
mb="xs"
label="取消结果模板ID"
{...form.getInputProps('cancelededTemplateId')}
/>
<TextInput
mb="xs"
label="延迟结果模板ID"
{...form.getInputProps('delayedTemplateId')}
/>
</>
)}
</Collapse>
<MultiTimePicker
remindTimes={form.values.remindTimes}
onChange={(val: any) => form.setValues({ remindTimes: val })}
/>
<Select
my="xs"
withAsterisk
label="订阅者类型"
data={['open_id', 'user_id', 'union_id', 'email', 'chat_id']}
description="可以和Bot说info获取"
checkIconPosition="right"
disabled={searchParams.has('subscriberType')}
{...form.getInputProps('subscriberType')}
/>
<TextInput
my="xs"
withAsterisk
label="订阅者ID"
disabled={searchParams.has('subscriberId')}
{...form.getInputProps('subscriberId')}
/>
<Button
fullWidth
variant="light"
my="lg"
type="submit"
>
</Button>
</form>
</Box>
);
}

25
view/src/server/index.ts Normal file
View File

@ -0,0 +1,25 @@
import PocketBase from 'pocketbase';
const pb = new PocketBase('https://eggpb.imoaix.cn');
const manage404 = async (dbFunc: any) => {
try {
const res = await dbFunc();
return res;
} catch (err: any) {
// 没有这个提醒就返回空
if (err.message === "The requested resource wasn't found.") {
return null;
}
throw err;
}
};
const getImgList = async () => {
const records = await pb.collection('remindImgs').getFullList({
sort: '-created',
});
return records;
};
export { getImgList, manage404 };

42
view/src/utils/time.ts Normal file
View File

@ -0,0 +1,42 @@
import moment from 'moment';
/**
*
* @returns YYYY-MM-DD HH:mm
*/
const getFullTimeStr = (time?: string) => moment(time).format('YYYY-MM-DD HH:mm');
/**
*
* @returns HH:mm
*/
const getTimeStr = (time?: string) => moment(time).format('HH:mm');
/**
* 202401Date对象
*/
const get202401Date = (dayofMonth: number) => moment('202401', 'YYYYMM').date(dayofMonth).toDate();
/**
* 202401Date对象提取出是几号
*/
const getDateFrom202401Date = (time: string) => Number(moment(time).format('DD'));
/**
* Date对象中获取MM-DD格式的时间
*/
const getMMDDFromDate = (time: string) => moment(time).format('MM-DD');
/**
* MM-DD格式的时间获取Date对象
*/
const getDateFromMMDD = (time: string) => moment(time, 'MM-DD').toDate();
export {
getFullTimeStr,
getTimeStr,
get202401Date,
getDateFrom202401Date,
getMMDDFromDate,
getDateFromMMDD,
};

View File

@ -2,7 +2,8 @@
"compilerOptions": {
"composite": true,
"module": "esnext",
"moduleResolution": "node"
"moduleResolution": "node",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

View File

@ -1,7 +1,14 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import path from 'path';
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
// 关键代码
'@': path.resolve(__dirname, './src'),
},
},
});

View File

@ -7677,6 +7677,11 @@ mkdirp@^1.0.3:
resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-1.0.4.tgz#3eb5ed62622756d79a5f0e2a221dfebad75c2f7e"
integrity sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==
moment@^2.29.4:
version "2.29.4"
resolved "https://pkgs.d.xiaomi.net:443/artifactory/api/npm/mi-npm/moment/-/moment-2.29.4.tgz#3dbe052889fe7c1b2ed966fcb3a77328964ef108"
integrity sha1-Pb4FKIn+fBsu2Wb8s6dzKJZO8Qg=
mri@^1.2.0:
version "1.2.0"
resolved "https://registry.yarnpkg.com/mri/-/mri-1.2.0.tgz#6721480fec2a11a4889861115a48b6cbe7cc8f0b"
@ -8129,6 +8134,11 @@ pkg-dir@^5.0.0:
dependencies:
find-up "^5.0.0"
pocketbase@^0.16.0:
version "0.16.0"
resolved "https://pkgs.d.xiaomi.net:443/artifactory/api/npm/mi-npm/pocketbase/-/pocketbase-0.16.0.tgz#156c05a26b126b97a202b7173e9c1544f090eff1"
integrity sha1-FWwFomsSa5eiArcXPpwVRPCQ7/E=
polished@^4.2.2:
version "4.2.2"
resolved "https://registry.yarnpkg.com/polished/-/polished-4.2.2.tgz#2529bb7c3198945373c52e34618c8fe7b1aa84d1"