feat(checklist): add daily checklist feature with admin management
Add daily checklist view for users to track daily tasks Support creating, editing, deleting, and drag-and-drop reordering in admin panel
This commit is contained in:
10
DESIGN.md
10
DESIGN.md
@@ -71,6 +71,10 @@ Pokemon 可配置:
|
|||||||
- 稀有度:1 ~ 3 星
|
- 稀有度:1 ~ 3 星
|
||||||
- 地图关联
|
- 地图关联
|
||||||
|
|
||||||
|
每日 CheckList 可配置:
|
||||||
|
- Task
|
||||||
|
- Task 顺序
|
||||||
|
|
||||||
## 功能
|
## 功能
|
||||||
|
|
||||||
- Pokemon 列表
|
- Pokemon 列表
|
||||||
@@ -116,6 +120,12 @@ Pokemon 可配置:
|
|||||||
- 基本信息
|
- 基本信息
|
||||||
- 入手方式
|
- 入手方式
|
||||||
- 需要材料列表
|
- 需要材料列表
|
||||||
|
- 每日 CheckList
|
||||||
|
- 展示每日做什么
|
||||||
|
- 每个 Task 可勾选
|
||||||
|
- 每天自动清空勾选状态,不删除 Task
|
||||||
|
- 管理中可新增 Task 到列表
|
||||||
|
- 管理中可通过 Handle 拖曳排序
|
||||||
|
|
||||||
## 用户系统
|
## 用户系统
|
||||||
|
|
||||||
|
|||||||
@@ -38,6 +38,19 @@ CREATE TABLE IF NOT EXISTS user_sessions (
|
|||||||
CREATE INDEX IF NOT EXISTS user_sessions_user_id_idx
|
CREATE INDEX IF NOT EXISTS user_sessions_user_id_idx
|
||||||
ON user_sessions(user_id);
|
ON user_sessions(user_id);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS daily_checklist_items (
|
||||||
|
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||||
|
title text NOT NULL,
|
||||||
|
sort_order integer NOT NULL DEFAULT 0 CHECK (sort_order >= 0),
|
||||||
|
created_by_user_id integer REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
updated_by_user_id integer REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS daily_checklist_items_sort_order_idx
|
||||||
|
ON daily_checklist_items(sort_order, id);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS skills (
|
CREATE TABLE IF NOT EXISTS skills (
|
||||||
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||||
name text NOT NULL UNIQUE,
|
name text NOT NULL UNIQUE,
|
||||||
|
|||||||
@@ -60,6 +60,10 @@ type RecipePayload = {
|
|||||||
materials: IdQuantity[];
|
materials: IdQuantity[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type DailyChecklistPayload = {
|
||||||
|
title: string;
|
||||||
|
};
|
||||||
|
|
||||||
type HabitatPayload = {
|
type HabitatPayload = {
|
||||||
name: string;
|
name: string;
|
||||||
recipeItems: IdQuantity[];
|
recipeItems: IdQuantity[];
|
||||||
@@ -522,6 +526,127 @@ export async function getOptions() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function cleanDailyChecklistPayload(payload: Record<string, unknown>): DailyChecklistPayload {
|
||||||
|
return {
|
||||||
|
title: cleanName(payload.title, '请输入 Task')
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listDailyChecklistItems() {
|
||||||
|
return query(
|
||||||
|
`
|
||||||
|
SELECT c.id, c.title
|
||||||
|
FROM daily_checklist_items c
|
||||||
|
ORDER BY c.sort_order, c.id
|
||||||
|
`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getDailyChecklistItemById(id: number) {
|
||||||
|
return queryOne(
|
||||||
|
`
|
||||||
|
SELECT c.id, c.title
|
||||||
|
FROM daily_checklist_items c
|
||||||
|
WHERE c.id = $1
|
||||||
|
`,
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createDailyChecklistItem(payload: Record<string, unknown>, userId: number) {
|
||||||
|
const cleanPayload = cleanDailyChecklistPayload(payload);
|
||||||
|
|
||||||
|
const id = await withTransaction(async (client) => {
|
||||||
|
const orderResult = await client.query<{ sortOrder: number }>(
|
||||||
|
'SELECT COALESCE(MAX(sort_order), 0) + 10 AS "sortOrder" FROM daily_checklist_items'
|
||||||
|
);
|
||||||
|
const sortOrder = orderResult.rows[0]?.sortOrder ?? 10;
|
||||||
|
|
||||||
|
const result = await client.query<{ id: number }>(
|
||||||
|
`
|
||||||
|
INSERT INTO daily_checklist_items (title, sort_order, created_by_user_id, updated_by_user_id)
|
||||||
|
VALUES ($1, $2, $3, $3)
|
||||||
|
RETURNING id
|
||||||
|
`,
|
||||||
|
[cleanPayload.title, sortOrder, userId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const createdId = result.rows[0].id;
|
||||||
|
await recordEditLog(client, 'daily-checklist-items', createdId, 'create', userId);
|
||||||
|
return createdId;
|
||||||
|
});
|
||||||
|
|
||||||
|
return getDailyChecklistItemById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateDailyChecklistItem(id: number, payload: Record<string, unknown>, userId: number) {
|
||||||
|
const cleanPayload = cleanDailyChecklistPayload(payload);
|
||||||
|
|
||||||
|
const updated = await withTransaction(async (client) => {
|
||||||
|
const result = await client.query(
|
||||||
|
`
|
||||||
|
UPDATE daily_checklist_items
|
||||||
|
SET title = $1, updated_by_user_id = $2, updated_at = now()
|
||||||
|
WHERE id = $3
|
||||||
|
`,
|
||||||
|
[cleanPayload.title, userId, id]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.rowCount === 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
await recordEditLog(client, 'daily-checklist-items', id, 'update', userId);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
return updated ? getDailyChecklistItemById(id) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function reorderDailyChecklistItems(payload: Record<string, unknown>, userId: number) {
|
||||||
|
const ids = cleanIds(payload.ids);
|
||||||
|
if (ids.length === 0) {
|
||||||
|
throw validationError('请选择 Task');
|
||||||
|
}
|
||||||
|
|
||||||
|
await withTransaction(async (client) => {
|
||||||
|
const existing = await client.query<{ id: number }>(
|
||||||
|
'SELECT id FROM daily_checklist_items WHERE id = ANY($1::integer[])',
|
||||||
|
[ids]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existing.rowCount !== ids.length) {
|
||||||
|
throw validationError('Task 不存在');
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [index, id] of ids.entries()) {
|
||||||
|
await client.query(
|
||||||
|
`
|
||||||
|
UPDATE daily_checklist_items
|
||||||
|
SET sort_order = $1, updated_by_user_id = $2, updated_at = now()
|
||||||
|
WHERE id = $3
|
||||||
|
`,
|
||||||
|
[(index + 1) * 10, userId, id]
|
||||||
|
);
|
||||||
|
await recordEditLog(client, 'daily-checklist-items', id, 'update', userId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return listDailyChecklistItems();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteDailyChecklistItem(id: number, userId: number) {
|
||||||
|
return withTransaction(async (client) => {
|
||||||
|
const result = await client.query<{ id: number }>('DELETE FROM daily_checklist_items WHERE id = $1 RETURNING id', [id]);
|
||||||
|
if (result.rowCount === 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
await recordEditLog(client, 'daily-checklist-items', id, 'delete', userId);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function isConfigType(type: string): type is ConfigType {
|
export function isConfigType(type: string): type is ConfigType {
|
||||||
return Object.hasOwn(configDefinitions, type);
|
return Object.hasOwn(configDefinitions, type);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,11 +5,13 @@ import { getUserBySessionToken, loginUser, logoutSession, registerUser, verifyEm
|
|||||||
import { initializeDatabase, pool } from './db.ts';
|
import { initializeDatabase, pool } from './db.ts';
|
||||||
import {
|
import {
|
||||||
createConfig,
|
createConfig,
|
||||||
|
createDailyChecklistItem,
|
||||||
createHabitat,
|
createHabitat,
|
||||||
createItem,
|
createItem,
|
||||||
createPokemon,
|
createPokemon,
|
||||||
createRecipe,
|
createRecipe,
|
||||||
deleteConfig,
|
deleteConfig,
|
||||||
|
deleteDailyChecklistItem,
|
||||||
deleteHabitat,
|
deleteHabitat,
|
||||||
deleteItem,
|
deleteItem,
|
||||||
deletePokemon,
|
deletePokemon,
|
||||||
@@ -21,11 +23,14 @@ import {
|
|||||||
getRecipe,
|
getRecipe,
|
||||||
isConfigType,
|
isConfigType,
|
||||||
listConfig,
|
listConfig,
|
||||||
|
listDailyChecklistItems,
|
||||||
listHabitats,
|
listHabitats,
|
||||||
listItems,
|
listItems,
|
||||||
listPokemon,
|
listPokemon,
|
||||||
listRecipes,
|
listRecipes,
|
||||||
|
reorderDailyChecklistItems,
|
||||||
updateConfig,
|
updateConfig,
|
||||||
|
updateDailyChecklistItem,
|
||||||
updateHabitat,
|
updateHabitat,
|
||||||
updateItem,
|
updateItem,
|
||||||
updatePokemon,
|
updatePokemon,
|
||||||
@@ -119,6 +124,8 @@ app.post('/api/auth/logout', async (request, reply) => {
|
|||||||
|
|
||||||
app.get('/api/options', async () => getOptions());
|
app.get('/api/options', async () => getOptions());
|
||||||
|
|
||||||
|
app.get('/api/daily-checklist', async () => listDailyChecklistItems());
|
||||||
|
|
||||||
app.get('/api/pokemon', async (request) => listPokemon(request.query as Record<string, string | string[] | undefined>));
|
app.get('/api/pokemon', async (request) => listPokemon(request.query as Record<string, string | string[] | undefined>));
|
||||||
|
|
||||||
app.get('/api/pokemon/:id', async (request, reply) => {
|
app.get('/api/pokemon/:id', async (request, reply) => {
|
||||||
@@ -291,6 +298,36 @@ app.delete('/api/recipes/:id', async (request, reply) => {
|
|||||||
return deleted ? reply.code(204).send() : reply.code(404).send({ message: 'Not found' });
|
return deleted ? reply.code(204).send() : reply.code(404).send({ message: 'Not found' });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.post('/api/admin/daily-checklist', async (request, reply) => {
|
||||||
|
const user = await requireVerifiedUser(request, reply);
|
||||||
|
return user ? reply.code(201).send(await createDailyChecklistItem(request.body as Record<string, unknown>, user.id)) : undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
app.put('/api/admin/daily-checklist/order', async (request, reply) => {
|
||||||
|
const user = await requireVerifiedUser(request, reply);
|
||||||
|
return user ? reorderDailyChecklistItems(request.body as Record<string, unknown>, user.id) : undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
app.put('/api/admin/daily-checklist/:id', async (request, reply) => {
|
||||||
|
const user = await requireVerifiedUser(request, reply);
|
||||||
|
if (!user) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { id } = request.params as { id: string };
|
||||||
|
const item = await updateDailyChecklistItem(Number(id), request.body as Record<string, unknown>, user.id);
|
||||||
|
return item ? item : reply.code(404).send({ message: 'Not found' });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete('/api/admin/daily-checklist/:id', async (request, reply) => {
|
||||||
|
const user = await requireVerifiedUser(request, reply);
|
||||||
|
if (!user) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { id } = request.params as { id: string };
|
||||||
|
const deleted = await deleteDailyChecklistItem(Number(id), user.id);
|
||||||
|
return deleted ? reply.code(204).send() : reply.code(404).send({ message: 'Not found' });
|
||||||
|
});
|
||||||
|
|
||||||
app.get('/api/admin/config/:type', async (request, reply) => {
|
app.get('/api/admin/config/:type', async (request, reply) => {
|
||||||
const user = await requireVerifiedUser(request, reply);
|
const user = await requireVerifiedUser(request, reply);
|
||||||
if (!user) {
|
if (!user) {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ const navItems = [
|
|||||||
{ label: '栖息地', to: '/habitats' },
|
{ label: '栖息地', to: '/habitats' },
|
||||||
{ label: '物品', to: '/items' },
|
{ label: '物品', to: '/items' },
|
||||||
{ label: '材料单', to: '/recipes' },
|
{ label: '材料单', to: '/recipes' },
|
||||||
|
{ label: 'CheckList', to: '/checklist' },
|
||||||
{ label: '管理', to: '/admin' }
|
{ label: '管理', to: '/admin' }
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import ItemEdit from '../views/ItemEdit.vue';
|
|||||||
import RecipeList from '../views/RecipeList.vue';
|
import RecipeList from '../views/RecipeList.vue';
|
||||||
import RecipeDetail from '../views/RecipeDetail.vue';
|
import RecipeDetail from '../views/RecipeDetail.vue';
|
||||||
import RecipeEdit from '../views/RecipeEdit.vue';
|
import RecipeEdit from '../views/RecipeEdit.vue';
|
||||||
|
import DailyChecklistView from '../views/DailyChecklistView.vue';
|
||||||
import AdminView from '../views/AdminView.vue';
|
import AdminView from '../views/AdminView.vue';
|
||||||
import LoginView from '../views/LoginView.vue';
|
import LoginView from '../views/LoginView.vue';
|
||||||
import RegisterView from '../views/RegisterView.vue';
|
import RegisterView from '../views/RegisterView.vue';
|
||||||
@@ -37,6 +38,7 @@ export const router = createRouter({
|
|||||||
{ path: '/recipes/new', component: RecipeEdit, meta: { requiresVerified: true } },
|
{ path: '/recipes/new', component: RecipeEdit, meta: { requiresVerified: true } },
|
||||||
{ path: '/recipes/:id/edit', component: RecipeEdit, meta: { requiresVerified: true } },
|
{ path: '/recipes/:id/edit', component: RecipeEdit, meta: { requiresVerified: true } },
|
||||||
{ path: '/recipes/:id', component: RecipeDetail },
|
{ path: '/recipes/:id', component: RecipeDetail },
|
||||||
|
{ path: '/checklist', component: DailyChecklistView },
|
||||||
{ path: '/admin', component: AdminView, meta: { requiresVerified: true } },
|
{ path: '/admin', component: AdminView, meta: { requiresVerified: true } },
|
||||||
{ path: '/login', component: LoginView },
|
{ path: '/login', component: LoginView },
|
||||||
{ path: '/register', component: RegisterView },
|
{ path: '/register', component: RegisterView },
|
||||||
|
|||||||
@@ -126,6 +126,11 @@ export interface Recipe extends EditInfo {
|
|||||||
materials: Array<NamedEntity & { quantity: number }>;
|
materials: Array<NamedEntity & { quantity: number }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DailyChecklistItem {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface RecipeDetail extends Recipe {
|
export interface RecipeDetail extends Recipe {
|
||||||
acquisition_methods: NamedEntity[];
|
acquisition_methods: NamedEntity[];
|
||||||
editHistory: EditHistoryEntry[];
|
editHistory: EditHistoryEntry[];
|
||||||
@@ -212,6 +217,10 @@ export interface HabitatPayload {
|
|||||||
}>;
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DailyChecklistPayload {
|
||||||
|
title: string;
|
||||||
|
}
|
||||||
|
|
||||||
export function buildQuery(params: Record<string, string | number | undefined>): string {
|
export function buildQuery(params: Record<string, string | number | undefined>): string {
|
||||||
const search = new URLSearchParams();
|
const search = new URLSearchParams();
|
||||||
|
|
||||||
@@ -329,6 +338,14 @@ export const api = {
|
|||||||
me: () => getJson<{ user: AuthUser }>('/api/auth/me'),
|
me: () => getJson<{ user: AuthUser }>('/api/auth/me'),
|
||||||
logout: () => postEmpty('/api/auth/logout'),
|
logout: () => postEmpty('/api/auth/logout'),
|
||||||
options: () => getJson<Options>('/api/options'),
|
options: () => getJson<Options>('/api/options'),
|
||||||
|
dailyChecklist: () => getJson<DailyChecklistItem[]>('/api/daily-checklist'),
|
||||||
|
createDailyChecklistItem: (payload: DailyChecklistPayload) =>
|
||||||
|
sendJson<DailyChecklistItem>('/api/admin/daily-checklist', 'POST', payload),
|
||||||
|
updateDailyChecklistItem: (id: string | number, payload: DailyChecklistPayload) =>
|
||||||
|
sendJson<DailyChecklistItem>(`/api/admin/daily-checklist/${id}`, 'PUT', payload),
|
||||||
|
reorderDailyChecklistItems: (ids: number[]) =>
|
||||||
|
sendJson<DailyChecklistItem[]>('/api/admin/daily-checklist/order', 'PUT', { ids }),
|
||||||
|
deleteDailyChecklistItem: (id: string | number) => deleteJson(`/api/admin/daily-checklist/${id}`),
|
||||||
config: (type: ConfigType) => getJson<Array<Skill | NamedEntity>>(`/api/admin/config/${type}`),
|
config: (type: ConfigType) => getJson<Array<Skill | NamedEntity>>(`/api/admin/config/${type}`),
|
||||||
createConfig: (type: ConfigType, payload: { name: string; hasItemDrop?: boolean }) =>
|
createConfig: (type: ConfigType, payload: { name: string; hasItemDrop?: boolean }) =>
|
||||||
sendJson<Skill | NamedEntity>(`/api/admin/config/${type}`, 'POST', payload),
|
sendJson<Skill | NamedEntity>(`/api/admin/config/${type}`, 'POST', payload),
|
||||||
|
|||||||
@@ -874,6 +874,175 @@ button:disabled,
|
|||||||
font-weight: 750;
|
font-weight: 750;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.checklist-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checklist-item {
|
||||||
|
padding: 14px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius-card);
|
||||||
|
background: var(--surface-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.checklist-check {
|
||||||
|
min-height: 34px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto minmax(0, 1fr);
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
color: var(--ink);
|
||||||
|
font-weight: 850;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checklist-check input {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
accent-color: var(--pokemon-blue);
|
||||||
|
}
|
||||||
|
|
||||||
|
.checklist-check span {
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checklist-item.is-checked .checklist-check span {
|
||||||
|
color: var(--muted);
|
||||||
|
text-decoration: line-through;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checklist-skeleton-list li {
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-checklist-row {
|
||||||
|
position: relative;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: flex-start;
|
||||||
|
border-radius: var(--radius-card);
|
||||||
|
transition:
|
||||||
|
background 0.16s ease,
|
||||||
|
box-shadow 0.16s ease,
|
||||||
|
opacity 0.16s ease,
|
||||||
|
transform 0.16s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-checklist-row.is-dragging {
|
||||||
|
z-index: 2;
|
||||||
|
background: color-mix(in srgb, var(--pokemon-yellow) 12%, var(--surface));
|
||||||
|
box-shadow: var(--shadow-soft);
|
||||||
|
opacity: 0.68;
|
||||||
|
transform: scale(0.99);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-checklist-row.is-drop-target::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
left: 0;
|
||||||
|
height: 3px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--pokemon-blue);
|
||||||
|
box-shadow: 0 0 0 3px color-mix(in srgb, var(--pokemon-blue) 18%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-checklist-row.is-drop-before::before {
|
||||||
|
top: -2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-checklist-row.is-drop-after::before {
|
||||||
|
bottom: -2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-checklist-move,
|
||||||
|
.admin-checklist-enter-active,
|
||||||
|
.admin-checklist-leave-active {
|
||||||
|
transition:
|
||||||
|
opacity 0.18s ease,
|
||||||
|
transform 0.18s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-checklist-enter-from,
|
||||||
|
.admin-checklist-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(6px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-checklist-leave-active {
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drag-handle {
|
||||||
|
width: 44px;
|
||||||
|
min-height: 44px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
display: inline-grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: 0;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius-control);
|
||||||
|
background: var(--surface-soft);
|
||||||
|
color: var(--muted);
|
||||||
|
cursor: grab;
|
||||||
|
touch-action: manipulation;
|
||||||
|
transition:
|
||||||
|
background 0.14s ease,
|
||||||
|
border-color 0.14s ease,
|
||||||
|
color 0.14s ease,
|
||||||
|
transform 0.14s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drag-handle:hover,
|
||||||
|
.drag-handle:focus-visible {
|
||||||
|
border-color: var(--pokemon-blue);
|
||||||
|
background: color-mix(in srgb, var(--pokemon-blue) 9%, var(--surface));
|
||||||
|
color: var(--pokemon-blue-deep);
|
||||||
|
}
|
||||||
|
|
||||||
|
.drag-handle:active {
|
||||||
|
cursor: grabbing;
|
||||||
|
transform: scale(0.96);
|
||||||
|
}
|
||||||
|
|
||||||
|
.drag-handle:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.54;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-checklist-title {
|
||||||
|
flex: 1 1 180px;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
color: var(--ink-soft);
|
||||||
|
font-weight: 850;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.admin-checklist-row,
|
||||||
|
.admin-checklist-move,
|
||||||
|
.admin-checklist-enter-active,
|
||||||
|
.admin-checklist-leave-active,
|
||||||
|
.drag-handle {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-checklist-row.is-dragging,
|
||||||
|
.admin-checklist-enter-from,
|
||||||
|
.admin-checklist-leave-to,
|
||||||
|
.drag-handle:active {
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.config-flag {
|
.config-flag {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
api,
|
api,
|
||||||
type AuthUser,
|
type AuthUser,
|
||||||
type ConfigType,
|
type ConfigType,
|
||||||
|
type DailyChecklistItem,
|
||||||
type Habitat,
|
type Habitat,
|
||||||
type Item,
|
type Item,
|
||||||
type NamedEntity,
|
type NamedEntity,
|
||||||
@@ -16,11 +17,12 @@ import {
|
|||||||
type Skill
|
type Skill
|
||||||
} from '../services/api';
|
} from '../services/api';
|
||||||
|
|
||||||
type AdminTab = 'config' | 'pokemon' | 'items' | 'recipes' | 'habitats';
|
type AdminTab = 'config' | 'checklist' | 'pokemon' | 'items' | 'recipes' | 'habitats';
|
||||||
type EditableConfig = (NamedEntity | Skill) & { hasItemDrop?: boolean };
|
type EditableConfig = (NamedEntity | Skill) & { hasItemDrop?: boolean };
|
||||||
|
|
||||||
const tabs: Array<{ key: AdminTab; label: string }> = [
|
const tabs: Array<{ key: AdminTab; label: string }> = [
|
||||||
{ key: 'config', label: '系统配置' },
|
{ key: 'config', label: '系统配置' },
|
||||||
|
{ key: 'checklist', label: 'CheckList' },
|
||||||
{ key: 'pokemon', label: 'Pokemon' },
|
{ key: 'pokemon', label: 'Pokemon' },
|
||||||
{ key: 'items', label: '物品' },
|
{ key: 'items', label: '物品' },
|
||||||
{ key: 'recipes', label: '材料单' },
|
{ key: 'recipes', label: '材料单' },
|
||||||
@@ -40,6 +42,7 @@ const configTypes: Array<{ key: ConfigType; label: string; supportsItemDrop?: bo
|
|||||||
const activeTab = ref<AdminTab>('config');
|
const activeTab = ref<AdminTab>('config');
|
||||||
const activeConfigType = ref<ConfigType>('skills');
|
const activeConfigType = ref<ConfigType>('skills');
|
||||||
const configRows = ref<EditableConfig[]>([]);
|
const configRows = ref<EditableConfig[]>([]);
|
||||||
|
const checklistRows = ref<DailyChecklistItem[]>([]);
|
||||||
const pokemonRows = ref<Pokemon[]>([]);
|
const pokemonRows = ref<Pokemon[]>([]);
|
||||||
const itemRows = ref<Item[]>([]);
|
const itemRows = ref<Item[]>([]);
|
||||||
const recipeRows = ref<Recipe[]>([]);
|
const recipeRows = ref<Recipe[]>([]);
|
||||||
@@ -49,6 +52,12 @@ const busy = ref(false);
|
|||||||
const contentLoading = ref(false);
|
const contentLoading = ref(false);
|
||||||
const message = ref('');
|
const message = ref('');
|
||||||
const configForm = ref({ id: 0, name: '', hasItemDrop: false });
|
const configForm = ref({ id: 0, name: '', hasItemDrop: false });
|
||||||
|
const checklistForm = ref({ id: 0, title: '' });
|
||||||
|
const draggingChecklistId = ref<number | null>(null);
|
||||||
|
const dragOverChecklistId = ref<number | null>(null);
|
||||||
|
const dragInsertAfterTarget = ref(false);
|
||||||
|
const dragSourceChecklistRows = ref<DailyChecklistItem[]>([]);
|
||||||
|
const dragDropCommitted = ref(false);
|
||||||
|
|
||||||
const selectedConfig = computed(() => configTypes.find((item) => item.key === activeConfigType.value) ?? configTypes[0]);
|
const selectedConfig = computed(() => configTypes.find((item) => item.key === activeConfigType.value) ?? configTypes[0]);
|
||||||
const configTabs = computed<TabOption[]>(() => configTypes.map((item) => ({ value: item.key, label: item.label })));
|
const configTabs = computed<TabOption[]>(() => configTypes.map((item) => ({ value: item.key, label: item.label })));
|
||||||
@@ -90,10 +99,59 @@ function resetConfigForm() {
|
|||||||
configForm.value = { id: 0, name: '', hasItemDrop: false };
|
configForm.value = { id: 0, name: '', hasItemDrop: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resetChecklistForm() {
|
||||||
|
checklistForm.value = { id: 0, title: '' };
|
||||||
|
}
|
||||||
|
|
||||||
function editConfig(item: EditableConfig) {
|
function editConfig(item: EditableConfig) {
|
||||||
configForm.value = { id: item.id, name: item.name, hasItemDrop: item.hasItemDrop === true };
|
configForm.value = { id: item.id, name: item.name, hasItemDrop: item.hasItemDrop === true };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function editChecklistItem(item: DailyChecklistItem) {
|
||||||
|
checklistForm.value = { id: item.id, title: item.title };
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasChecklistOrderChanged(rows: DailyChecklistItem[], nextRows: DailyChecklistItem[]) {
|
||||||
|
return rows.length !== nextRows.length || rows.some((item, index) => item.id !== nextRows[index]?.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function reorderedChecklistRows(
|
||||||
|
rows: DailyChecklistItem[],
|
||||||
|
draggedId: number,
|
||||||
|
targetId: number,
|
||||||
|
insertAfterTarget: boolean
|
||||||
|
) {
|
||||||
|
if (draggedId === targetId) {
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
const draggedItem = rows.find((item) => item.id === draggedId);
|
||||||
|
if (!draggedItem) {
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextRows = rows.filter((item) => item.id !== draggedId);
|
||||||
|
const targetIndex = nextRows.findIndex((item) => item.id === targetId);
|
||||||
|
if (targetIndex < 0) {
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
nextRows.splice(targetIndex + (insertAfterTarget ? 1 : 0), 0, draggedItem);
|
||||||
|
return nextRows;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function persistChecklistOrder(nextRows: DailyChecklistItem[], fallbackRows: DailyChecklistItem[]) {
|
||||||
|
checklistRows.value = nextRows;
|
||||||
|
await run(async () => {
|
||||||
|
try {
|
||||||
|
checklistRows.value = await api.reorderDailyChecklistItems(nextRows.map((item) => item.id));
|
||||||
|
} catch (error) {
|
||||||
|
checklistRows.value = fallbackRows;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function saveConfig() {
|
async function saveConfig() {
|
||||||
await run(async () => {
|
await run(async () => {
|
||||||
const payload = {
|
const payload = {
|
||||||
@@ -112,6 +170,30 @@ async function saveConfig() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadChecklist() {
|
||||||
|
checklistRows.value = await api.dailyChecklist();
|
||||||
|
if (!checklistForm.value.id && checklistForm.value.title.trim() === '') {
|
||||||
|
resetChecklistForm();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveChecklistItem() {
|
||||||
|
await run(async () => {
|
||||||
|
const payload = {
|
||||||
|
title: checklistForm.value.title
|
||||||
|
};
|
||||||
|
|
||||||
|
if (checklistForm.value.id) {
|
||||||
|
await api.updateDailyChecklistItem(checklistForm.value.id, payload);
|
||||||
|
} else {
|
||||||
|
await api.createDailyChecklistItem(payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
await loadChecklist();
|
||||||
|
resetChecklistForm();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function loadPokemon() {
|
async function loadPokemon() {
|
||||||
pokemonRows.value = await api.pokemon({});
|
pokemonRows.value = await api.pokemon({});
|
||||||
}
|
}
|
||||||
@@ -135,6 +217,7 @@ async function loadCurrentTab(showSkeleton = false) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
if (activeTab.value === 'config') await loadConfig();
|
if (activeTab.value === 'config') await loadConfig();
|
||||||
|
if (activeTab.value === 'checklist') await loadChecklist();
|
||||||
if (activeTab.value === 'pokemon') await loadPokemon();
|
if (activeTab.value === 'pokemon') await loadPokemon();
|
||||||
if (activeTab.value === 'items') await loadItems();
|
if (activeTab.value === 'items') await loadItems();
|
||||||
if (activeTab.value === 'recipes') await loadRecipes();
|
if (activeTab.value === 'recipes') await loadRecipes();
|
||||||
@@ -178,6 +261,129 @@ async function removeConfig(id: number) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function removeChecklistItem(id: number) {
|
||||||
|
await run(async () => {
|
||||||
|
await api.deleteDailyChecklistItem(id);
|
||||||
|
if (checklistForm.value.id === id) {
|
||||||
|
resetChecklistForm();
|
||||||
|
}
|
||||||
|
await loadChecklist();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function startChecklistDrag(item: DailyChecklistItem, event: Event) {
|
||||||
|
draggingChecklistId.value = item.id;
|
||||||
|
dragSourceChecklistRows.value = [...checklistRows.value];
|
||||||
|
dragDropCommitted.value = false;
|
||||||
|
const dragEvent = event instanceof DragEvent ? event : null;
|
||||||
|
dragEvent?.dataTransfer?.setData('text/plain', String(item.id));
|
||||||
|
if (dragEvent?.dataTransfer) {
|
||||||
|
dragEvent.dataTransfer.effectAllowed = 'move';
|
||||||
|
dragEvent.dataTransfer.dropEffect = 'move';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearChecklistDragState() {
|
||||||
|
draggingChecklistId.value = null;
|
||||||
|
dragOverChecklistId.value = null;
|
||||||
|
dragInsertAfterTarget.value = false;
|
||||||
|
dragSourceChecklistRows.value = [];
|
||||||
|
dragDropCommitted.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function endChecklistDrag() {
|
||||||
|
if (draggingChecklistId.value !== null && !dragDropCommitted.value && dragSourceChecklistRows.value.length) {
|
||||||
|
checklistRows.value = dragSourceChecklistRows.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
clearChecklistDragState();
|
||||||
|
}
|
||||||
|
|
||||||
|
function previewChecklistDrop(targetItem: DailyChecklistItem, event: Event) {
|
||||||
|
const dragEvent = event instanceof DragEvent ? event : null;
|
||||||
|
const draggedId = draggingChecklistId.value ?? Number(dragEvent?.dataTransfer?.getData('text/plain'));
|
||||||
|
if (!draggedId || busy.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (draggedId === targetItem.id) {
|
||||||
|
dragOverChecklistId.value = null;
|
||||||
|
dragInsertAfterTarget.value = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dragEvent?.dataTransfer) {
|
||||||
|
dragEvent.dataTransfer.dropEffect = 'move';
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetElement = event.currentTarget instanceof HTMLElement ? event.currentTarget : null;
|
||||||
|
const insertAfterTarget = targetElement
|
||||||
|
? (dragEvent?.clientY ?? 0) > targetElement.getBoundingClientRect().top + targetElement.getBoundingClientRect().height / 2
|
||||||
|
: false;
|
||||||
|
|
||||||
|
dragOverChecklistId.value = targetItem.id;
|
||||||
|
dragInsertAfterTarget.value = insertAfterTarget;
|
||||||
|
const nextRows = reorderedChecklistRows(checklistRows.value, draggedId, targetItem.id, insertAfterTarget);
|
||||||
|
if (hasChecklistOrderChanged(checklistRows.value, nextRows)) {
|
||||||
|
checklistRows.value = nextRows;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function dropChecklistItem(targetItem: DailyChecklistItem, event: Event) {
|
||||||
|
if (!draggingChecklistId.value || busy.value) {
|
||||||
|
endChecklistDrag();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
previewChecklistDrop(targetItem, event);
|
||||||
|
|
||||||
|
const nextRows = [...checklistRows.value];
|
||||||
|
const fallbackRows = dragSourceChecklistRows.value.length ? [...dragSourceChecklistRows.value] : nextRows;
|
||||||
|
dragDropCommitted.value = true;
|
||||||
|
clearChecklistDragState();
|
||||||
|
|
||||||
|
if (!hasChecklistOrderChanged(fallbackRows, nextRows)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await persistChecklistOrder(nextRows, fallbackRows);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function moveChecklistItemByKeyboard(item: DailyChecklistItem, offset: -1 | 1) {
|
||||||
|
if (busy.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentIndex = checklistRows.value.findIndex((row) => row.id === item.id);
|
||||||
|
const targetIndex = currentIndex + offset;
|
||||||
|
if (currentIndex < 0 || targetIndex < 0 || targetIndex >= checklistRows.value.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fallbackRows = [...checklistRows.value];
|
||||||
|
const nextRows = [...checklistRows.value];
|
||||||
|
const [movedItem] = nextRows.splice(currentIndex, 1);
|
||||||
|
nextRows.splice(targetIndex, 0, movedItem);
|
||||||
|
await persistChecklistOrder(nextRows, fallbackRows);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleChecklistHandleKey(item: DailyChecklistItem, event: Event) {
|
||||||
|
const keyboardEvent = event instanceof KeyboardEvent ? event : null;
|
||||||
|
if (!keyboardEvent) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (keyboardEvent.key === 'ArrowUp') {
|
||||||
|
keyboardEvent.preventDefault();
|
||||||
|
void moveChecklistItemByKeyboard(item, -1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (keyboardEvent.key === 'ArrowDown') {
|
||||||
|
keyboardEvent.preventDefault();
|
||||||
|
void moveChecklistItemByKeyboard(item, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function removePokemon(id: number) {
|
async function removePokemon(id: number) {
|
||||||
await run(async () => {
|
await run(async () => {
|
||||||
await api.deletePokemon(id);
|
await api.deletePokemon(id);
|
||||||
@@ -237,6 +443,59 @@ onMounted(() => {
|
|||||||
</ul>
|
</ul>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section v-else-if="canEdit && activeTab === 'checklist'" class="detail-section">
|
||||||
|
<h2>CheckList</h2>
|
||||||
|
|
||||||
|
<form class="detail-section__body" @submit.prevent="saveChecklistItem">
|
||||||
|
<h3 class="section-subtitle">{{ checklistForm.id ? '编辑 Task' : '新增 Task' }}</h3>
|
||||||
|
<div class="field">
|
||||||
|
<label for="checklist-title">Task</label>
|
||||||
|
<input id="checklist-title" v-model="checklistForm.title" required />
|
||||||
|
</div>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button type="submit" class="link-button" :disabled="busy">{{ busy ? '保存中' : '保存' }}</button>
|
||||||
|
<button type="button" class="plain-button" :disabled="busy" @click="resetChecklistForm">新建</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<h3 class="section-subtitle">每日做什么</h3>
|
||||||
|
<TransitionGroup v-if="checklistRows.length" name="admin-checklist" tag="ul" class="row-list admin-checklist-list">
|
||||||
|
<li
|
||||||
|
v-for="item in checklistRows"
|
||||||
|
:key="item.id"
|
||||||
|
class="admin-checklist-row"
|
||||||
|
:class="{
|
||||||
|
'is-dragging': draggingChecklistId === item.id,
|
||||||
|
'is-drop-target': dragOverChecklistId === item.id,
|
||||||
|
'is-drop-after': dragOverChecklistId === item.id && dragInsertAfterTarget,
|
||||||
|
'is-drop-before': dragOverChecklistId === item.id && !dragInsertAfterTarget
|
||||||
|
}"
|
||||||
|
@dragover.prevent="previewChecklistDrop(item, $event)"
|
||||||
|
@drop.prevent="dropChecklistItem(item, $event)"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="drag-handle"
|
||||||
|
draggable="true"
|
||||||
|
:aria-label="`拖曳排序:${item.title}`"
|
||||||
|
title="拖曳排序"
|
||||||
|
:disabled="busy"
|
||||||
|
@dragstart="startChecklistDrag(item, $event)"
|
||||||
|
@dragend="endChecklistDrag"
|
||||||
|
@keydown="handleChecklistHandleKey(item, $event)"
|
||||||
|
>
|
||||||
|
<span aria-hidden="true">⋮⋮</span>
|
||||||
|
</button>
|
||||||
|
<span class="admin-checklist-title">{{ item.title }}</span>
|
||||||
|
<span class="row-actions">
|
||||||
|
<button type="button" :disabled="busy" @click="editChecklistItem(item)">编辑</button>
|
||||||
|
<button type="button" :disabled="busy" @click="removeChecklistItem(item.id)">删除</button>
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
</TransitionGroup>
|
||||||
|
<p v-else class="meta-line">暂无记录</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section v-else-if="canEdit && activeTab === 'config'" class="detail-section">
|
<section v-else-if="canEdit && activeTab === 'config'" class="detail-section">
|
||||||
<h2>系统配置</h2>
|
<h2>系统配置</h2>
|
||||||
<Tabs id="admin-config-type" v-model="activeConfigTab" :tabs="configTabs" label="系统配置类型" />
|
<Tabs id="admin-config-type" v-model="activeConfigTab" :tabs="configTabs" label="系统配置类型" />
|
||||||
|
|||||||
141
frontend/src/views/DailyChecklistView.vue
Normal file
141
frontend/src/views/DailyChecklistView.vue
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, onUnmounted, ref } from 'vue';
|
||||||
|
import PageHeader from '../components/PageHeader.vue';
|
||||||
|
import Skeleton from '../components/Skeleton.vue';
|
||||||
|
import { api, type DailyChecklistItem } from '../services/api';
|
||||||
|
|
||||||
|
type ChecklistState = {
|
||||||
|
date: string;
|
||||||
|
checkedIds: number[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const checklistStateKey = 'pokopia_daily_checklist_state';
|
||||||
|
const stateRefreshIntervalMs = 60_000;
|
||||||
|
const checklistItems = ref<DailyChecklistItem[]>([]);
|
||||||
|
const checkedTaskIds = ref<Set<number>>(new Set());
|
||||||
|
const loading = ref(true);
|
||||||
|
const skeletonRows = 5;
|
||||||
|
let stateRefreshTimer: number | null = null;
|
||||||
|
|
||||||
|
function todayKey() {
|
||||||
|
const today = new Date();
|
||||||
|
const year = today.getFullYear();
|
||||||
|
const month = String(today.getMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(today.getDate()).padStart(2, '0');
|
||||||
|
return `${year}-${month}-${day}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function persistChecklistState() {
|
||||||
|
if (typeof localStorage === 'undefined') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const state: ChecklistState = {
|
||||||
|
date: todayKey(),
|
||||||
|
checkedIds: [...checkedTaskIds.value]
|
||||||
|
};
|
||||||
|
localStorage.setItem(checklistStateKey, JSON.stringify(state));
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadChecklistState() {
|
||||||
|
if (typeof localStorage === 'undefined') {
|
||||||
|
checkedTaskIds.value = new Set();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const state = JSON.parse(localStorage.getItem(checklistStateKey) ?? 'null') as Partial<ChecklistState> | null;
|
||||||
|
checkedTaskIds.value = state?.date === todayKey() ? new Set(state.checkedIds?.filter((id) => Number.isInteger(id))) : new Set();
|
||||||
|
} catch {
|
||||||
|
checkedTaskIds.value = new Set();
|
||||||
|
}
|
||||||
|
|
||||||
|
persistChecklistState();
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncChecklistState() {
|
||||||
|
const checklistIds = new Set(checklistItems.value.map((item) => item.id));
|
||||||
|
const nextCheckedIds = new Set([...checkedTaskIds.value].filter((id) => checklistIds.has(id)));
|
||||||
|
if (nextCheckedIds.size !== checkedTaskIds.value.size) {
|
||||||
|
checkedTaskIds.value = nextCheckedIds;
|
||||||
|
persistChecklistState();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isTaskChecked(id: number) {
|
||||||
|
return checkedTaskIds.value.has(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleTask(id: number, checked: boolean) {
|
||||||
|
const nextCheckedIds = new Set(checkedTaskIds.value);
|
||||||
|
if (checked) {
|
||||||
|
nextCheckedIds.add(id);
|
||||||
|
} else {
|
||||||
|
nextCheckedIds.delete(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
checkedTaskIds.value = nextCheckedIds;
|
||||||
|
persistChecklistState();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleTaskChange(id: number, event: Event) {
|
||||||
|
const checkbox = event.target instanceof HTMLInputElement ? event.target : null;
|
||||||
|
toggleTask(id, checkbox?.checked === true);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadDailyChecklist() {
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
checklistItems.value = await api.dailyChecklist();
|
||||||
|
syncChecklistState();
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadChecklistState();
|
||||||
|
stateRefreshTimer = window.setInterval(loadChecklistState, stateRefreshIntervalMs);
|
||||||
|
void loadDailyChecklist();
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (stateRefreshTimer !== null) {
|
||||||
|
window.clearInterval(stateRefreshTimer);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="page-stack">
|
||||||
|
<PageHeader title="每日清单" subtitle="查看每天可以完成的事项。">
|
||||||
|
<template #kicker>CheckList</template>
|
||||||
|
</PageHeader>
|
||||||
|
|
||||||
|
<section class="detail-section" :aria-busy="loading">
|
||||||
|
<h2>每日做什么</h2>
|
||||||
|
|
||||||
|
<ul v-if="loading" class="row-list skeleton-row-list checklist-skeleton-list" aria-label="正在加载每日清单">
|
||||||
|
<li v-for="index in skeletonRows" :key="index">
|
||||||
|
<Skeleton variant="box" width="34px" height="34px" />
|
||||||
|
<Skeleton :width="index % 2 === 0 ? '220px' : '160px'" />
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<ul v-else-if="checklistItems.length" class="checklist-list">
|
||||||
|
<li v-for="item in checklistItems" :key="item.id" class="checklist-item" :class="{ 'is-checked': isTaskChecked(item.id) }">
|
||||||
|
<label class="checklist-check">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
:checked="isTaskChecked(item.id)"
|
||||||
|
@change="handleTaskChange(item.id, $event)"
|
||||||
|
/>
|
||||||
|
<span>{{ item.title }}</span>
|
||||||
|
</label>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<p v-else class="meta-line">暂无每日清单</p>
|
||||||
|
</section>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
Reference in New Issue
Block a user