@wordrhyme/auto-crud 1.18.0 → 1.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1948,7 +1948,7 @@ import {
1948
1948
  createLocalViewStorage,
1949
1949
  } from '@wordrhyme/auto-crud';
1950
1950
 
1951
- // 服务对象应保持引用稳定。后端存储只需实现 ViewStorage 的四个方法。
1951
+ // 服务对象应保持引用稳定。后端存储实现 ViewStorage 的四个必需方法,可选实现 reorder。
1952
1952
  const viewStorage = createLocalViewStorage(`${organizationId}:${userId}`);
1953
1953
 
1954
1954
  <QueryClientProvider client={queryClient}>
@@ -1984,6 +1984,37 @@ const viewStorage = createLocalViewStorage(`${organizationId}:${userId}`);
1984
1984
 
1985
1985
  展示方式与当前选中视图的 ID 自动存入浏览器 `localStorage`,键为 `auto-crud:view-preferences:${encodeURIComponent(scope)}:${encodeURIComponent(resourceId)}`,按用户/租户和资源隔离,独立于 `ViewStorage` 的本地或服务端实现。刷新或重新进入时恢复展示方式;视图列表加载成功后恢复选中项。URL 已有筛选、搜索、排序或分页参数时保留 URL 查询,否则应用上次视图的保存条件,使用 replace 更新 URL。上次视图已删除或字段不可用时清除其选中记忆,保留 URL 查询。本地数据损坏或存储不可用时使用配置的初始展示方式,仍可在当前页面切换。未保存的查询不另存到本地偏好中。
1986
1986
 
1987
+ ### 默认视图、分享链接与排列顺序
1988
+
1989
+ ```tsx
1990
+ <AutoCrudTable
1991
+ id="crm.customers"
1992
+ schema={schema}
1993
+ resource={resource}
1994
+ table={{
1995
+ views: { defaultView: 'vip-customers', selector: 'tabs' },
1996
+ // 可选;同页多个列表或字段名与 view 冲突时使用不同的 URL 参数名。
1997
+ queryKeys: { view: 'customerView' },
1998
+ }}
1999
+ />
2000
+ ```
2001
+
2002
+ `defaultView` 接受视图的 slug 或 ID,独立 `AutoTable` 使用 `views={{ defaultView: 'vip-customers' }}`。初始化优先级为 URL 指定、用户本地选中记忆、`defaultView`、全部;本地明确记住“全部”也优先于默认值。默认项不存在时回到全部,不自动选择其他视图。`views` 仍须显式开启,本地偏好与 URL 都不会自动开启视图功能。
2003
+
2004
+ 视图可包含可选的 `slug`,例如 `{ id: 'view-123', slug: 'vip-customers', name: '重点客户', query }`。新建时按名称建议 slug,可手动修改;重命名保持原 slug。slug 为 1–80 个文字、数字或连接用的短横线,区分大小写。`ViewStorage.create/update` 须保存并返回该字段,`update` 未传 slug 时保留原值;已有数据无需迁移,没有 slug 时继续使用 ID。同一资源内 ID 和 slug 必须唯一,slug 也不能与另一视图的 ID 相同。服务端须在用户/租户/资源边界内校验唯一性;修改 slug 后旧 slug 链接失效,稳定 ID 仍可定位。
2005
+
2006
+ 默认参数为 `?view=vip-customers`,也接受 `?view=view-123`;若配置上例的 `queryKeys.view`,则使用 `?customerView=vip-customers`。切换视图会同步参数并创建浏览器历史记录,前进/后退恢复 URL 指定的视图。有 slug 时写入规范 slug,只有 ID 的旧链接会以 replace 规范化。`?view=` 明确表示“全部”。仅带视图参数的链接会应用保存条件;若同时带筛选、搜索、排序或分页参数,保留完整 URL 查询,便于分享未保存的调整。无权访问、已删除或查询字段失效时显示错误并保留 URL 查询,不回退到另一个视图。链接接收者仍需有权限读取该视图及资源,URL 不授予访问权限。
2007
+
2008
+ 下拉和 Tab 都按 `ViewStorage.list()` 的返回顺序展示,“全部”固定在最前。存储提供可选方法后,设置菜单显示“调整顺序”,拖动手柄(也支持键盘)后点击保存;取消或保存失败不会改变显示顺序或当前选中项:
2009
+
2010
+ ```ts
2011
+ reorder?: (resourceId: string, ids: string[]) => Promise<void>;
2012
+ ```
2013
+
2014
+ `ids` 必须包含该资源的全部视图 ID,顺序即新顺序。服务端应在事务中校验并保存,拒绝重复、未知或缺失 ID;并发新增/删除导致列表不一致时返回错误,让用户刷新后重试。成功后组件重新调用 `list()`。排序不修改视图 ID、slug 或查询条件;服务未实现 `reorder` 时不展示排序入口。`createLocalViewStorage` 已实现本地排序持久化。
2015
+
2016
+ Storybook 的 **Default View** 演示按 slug 默认选中,“视图设置 → 调整顺序”可体验排序;所有 Saved views and tags 示例共用本地视图存储,首次加载补齐演示视图;视图、删除操作和排列顺序刷新后保留。客户标签仍为刷新后重置的内存数据。
2017
+
1987
2018
  标签列表调用示例:
1988
2019
 
1989
2020
  ```ts
@@ -2032,7 +2063,7 @@ const loadViewCounts: NonNullable<SavedViewsConfig['counts']> = async ({
2032
2063
 
2033
2064
  首次加载显示 `…`,零结果显示 `0`,缺失或无效数量显示 `—`;请求失败显示 `—` 和重试入口。保存视图条件后重新统计;表格数据引用或 `counts` 回调引用变化时也会重新获取。数量由查询缓存管理,不写入视图存储。独立 `AutoTable` 使用 `views={{ selector: 'tabs', counts: loadViewCounts }}`。
2034
2065
 
2035
- Storybook 的 **Auto CRUD / Saved views and tags / Tabs With Counts** 使用内存视图与标签数据,模拟 300ms 请求延迟,预置数量为 3、2、1、0 的视图。列表和数量共用筛选逻辑,修改客户标签后会重新计算;刷新页面后重置示例数据。
2066
+ Storybook 的 **Auto CRUD / Saved views and tags / Tabs With Counts** 与其他视图示例共用本地视图存储,模拟 300ms 统计请求延迟;首次加载预置数量为 3、2、1、0 的视图。列表和数量共用筛选逻辑,修改客户标签后会重新计算。视图与排列顺序刷新后保留,客户标签刷新后重置。
2036
2067
 
2037
2068
  ### 视图 Tab 样式
2038
2069
 
package/dist/index.cjs CHANGED
@@ -15,12 +15,12 @@ let clsx = require("clsx");
15
15
  clsx = require_position.__toESM(clsx);
16
16
  let tailwind_merge = require("tailwind-merge");
17
17
  tailwind_merge = require_position.__toESM(tailwind_merge);
18
+ let __wordrhyme_shadcn_ui = require("@wordrhyme/shadcn-ui");
19
+ __wordrhyme_shadcn_ui = require_position.__toESM(__wordrhyme_shadcn_ui);
18
20
  let nanoid = require("nanoid");
19
21
  nanoid = require_position.__toESM(nanoid);
20
22
  let __tanstack_react_table = require("@tanstack/react-table");
21
23
  __tanstack_react_table = require_position.__toESM(__tanstack_react_table);
22
- let __wordrhyme_shadcn_ui = require("@wordrhyme/shadcn-ui");
23
- __wordrhyme_shadcn_ui = require_position.__toESM(__wordrhyme_shadcn_ui);
24
24
  let __wordrhyme_ui = require("@wordrhyme/ui");
25
25
  __wordrhyme_ui = require_position.__toESM(__wordrhyme_ui);
26
26
  let __wordrhyme_formily_shadcn = require("@wordrhyme/formily-shadcn");
@@ -1445,9 +1445,32 @@ const viewQuerySchema = zod.z.object({
1445
1445
  });
1446
1446
  const viewInputSchema = zod.z.object({
1447
1447
  name: zod.z.string().trim().min(1).max(40),
1448
+ slug: zod.z.string().trim().min(1).max(80).regex(/^[\p{L}\p{N}]+(?:-[\p{L}\p{N}]+)*$/u, "Slug 只能包含文字、数字和连接用的短横线").optional(),
1448
1449
  query: viewQuerySchema
1449
1450
  });
1450
1451
  const savedViewSchema = viewInputSchema.extend({ id: zod.z.string().min(1) });
1452
+ /** IDs and slugs share a lookup namespace within a resource. */
1453
+ function parseSavedViews(value) {
1454
+ const views = zod.z.array(savedViewSchema).parse(value);
1455
+ const ids = /* @__PURE__ */ new Set();
1456
+ const references = /* @__PURE__ */ new Map();
1457
+ for (const view of views) {
1458
+ if (ids.has(view.id)) throw new Error("视图 ID 重复");
1459
+ ids.add(view.id);
1460
+ for (const reference of [view.id, view.slug].filter((item) => item !== void 0)) {
1461
+ const owner = references.get(reference);
1462
+ if (owner !== void 0 && owner !== view.id) throw new Error("视图 slug 必须唯一,且不能与其他视图的 ID 相同");
1463
+ references.set(reference, view.id);
1464
+ }
1465
+ }
1466
+ return views;
1467
+ }
1468
+ /** Require a complete permutation so stale lists cannot silently drop views. */
1469
+ function reorderSavedViews(views, ids) {
1470
+ const byId = new Map(views.map((view) => [view.id, view]));
1471
+ if (ids.length !== views.length || new Set(ids).size !== ids.length || ids.some((id) => !byId.has(id))) throw new Error("视图列表已变化,请取消后重新调整顺序");
1472
+ return ids.map((id) => byId.get(id));
1473
+ }
1451
1474
  const viewPreferencesSchema = zod.z.object({
1452
1475
  selector: zod.z.enum(["dropdown", "tabs"]),
1453
1476
  viewId: zod.z.string().min(1).nullable()
@@ -1491,8 +1514,8 @@ function queryFingerprint(query) {
1491
1514
  function createLocalViewStorage(namespace) {
1492
1515
  if (!namespace.trim()) throw new Error("A user/tenant storage namespace is required");
1493
1516
  const key = (resourceId) => `auto-crud:views:${encodeURIComponent(namespace)}:${encodeURIComponent(resourceId)}`;
1494
- const read = (resourceId) => zod.z.array(savedViewSchema).parse(JSON.parse(localStorage.getItem(key(resourceId)) ?? "[]"));
1495
- const write = (resourceId, views) => localStorage.setItem(key(resourceId), JSON.stringify(views));
1517
+ const read = (resourceId) => parseSavedViews(JSON.parse(localStorage.getItem(key(resourceId)) ?? "[]"));
1518
+ const write = (resourceId, views) => localStorage.setItem(key(resourceId), JSON.stringify(parseSavedViews(views)));
1496
1519
  return {
1497
1520
  async list(resourceId) {
1498
1521
  return read(resourceId);
@@ -1507,9 +1530,12 @@ function createLocalViewStorage(namespace) {
1507
1530
  },
1508
1531
  async update(resourceId, id, input) {
1509
1532
  const views = read(resourceId);
1510
- if (!views.some((view$1) => view$1.id === id)) throw new Error("View not found");
1533
+ const current = views.find((view$1) => view$1.id === id);
1534
+ if (!current) throw new Error("View not found");
1535
+ const valid = viewInputSchema.parse(input);
1511
1536
  const view = {
1512
- ...viewInputSchema.parse(input),
1537
+ ...valid,
1538
+ slug: valid.slug ?? current.slug,
1513
1539
  id
1514
1540
  };
1515
1541
  write(resourceId, views.map((item) => item.id === id ? view : item));
@@ -1517,19 +1543,32 @@ function createLocalViewStorage(namespace) {
1517
1543
  },
1518
1544
  async remove(resourceId, id) {
1519
1545
  write(resourceId, read(resourceId).filter((view) => view.id !== id));
1546
+ },
1547
+ async reorder(resourceId, ids) {
1548
+ write(resourceId, reorderSavedViews(read(resourceId), ids));
1520
1549
  }
1521
1550
  };
1522
1551
  }
1523
1552
 
1524
1553
  //#endregion
1525
1554
  //#region src/components/auto-crud/saved-views.tsx
1526
- function SavedViews({ resourceId, table, fieldsReady, selector, tabs, counts, onApply, children }) {
1555
+ function suggestSlug(name, views) {
1556
+ if (!name.trim()) return "";
1557
+ const base = name.normalize("NFKC").toLowerCase().replace(/[^\p{L}\p{N}]+/gu, "-").replace(/^-|-$/gu, "").slice(0, 70).replace(/-$/gu, "") || "view";
1558
+ const references = new Set(views.flatMap((view) => [view.id, view.slug]));
1559
+ let slug = base;
1560
+ let suffix = 2;
1561
+ while (references.has(slug)) slug = `${base}-${suffix++}`;
1562
+ return slug;
1563
+ }
1564
+ function SavedViews({ resourceId, table, fieldsReady, defaultView, selector, tabs, counts, onApply, children }) {
1527
1565
  const config = useAutoCrudConfig();
1528
1566
  if (!config?.viewStorage) throw new Error("Saved views require AutoCrudProvider.viewStorage");
1529
1567
  return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ViewMenu, {
1530
1568
  resourceId,
1531
1569
  table,
1532
1570
  fieldsReady,
1571
+ defaultView,
1533
1572
  onApply,
1534
1573
  selector,
1535
1574
  tabs,
@@ -1537,7 +1576,7 @@ function SavedViews({ resourceId, table, fieldsReady, selector, tabs, counts, on
1537
1576
  children
1538
1577
  }, `${config.cacheKey}:${resourceId}`);
1539
1578
  }
1540
- function ViewMenu({ resourceId, table, fieldsReady = true, selector = "dropdown", tabs, counts, onApply, children }) {
1579
+ function ViewMenu({ resourceId, table, fieldsReady = true, defaultView, selector = "dropdown", tabs, counts, onApply, children }) {
1541
1580
  const config = useAutoCrudConfig();
1542
1581
  const storage = config.viewStorage;
1543
1582
  const client = (0, __tanstack_react_query.useQueryClient)();
@@ -1548,7 +1587,7 @@ function ViewMenu({ resourceId, table, fieldsReady = true, selector = "dropdown"
1548
1587
  ];
1549
1588
  const views = (0, __tanstack_react_query.useQuery)({
1550
1589
  queryKey,
1551
- queryFn: async () => (await storage.list(resourceId)).map((view) => savedViewSchema.parse(view))
1590
+ queryFn: async () => parseSavedViews(await storage.list(resourceId))
1552
1591
  });
1553
1592
  const countQueries = (0, react.useMemo)(() => [{
1554
1593
  key: "all",
@@ -1632,6 +1671,8 @@ function ViewMenu({ resourceId, table, fieldsReady = true, selector = "dropdown"
1632
1671
  }, [config.scope, resourceId]);
1633
1672
  const [dialog, setDialog] = (0, react.useState)(null);
1634
1673
  const [name, setName] = (0, react.useState)("");
1674
+ const [slug, setSlug] = (0, react.useState)("");
1675
+ const [orderedViews, setOrderedViews] = (0, react.useState)([]);
1635
1676
  const [error, setError] = (0, react.useState)(null);
1636
1677
  const [search, setSearch] = (0, react.useState)(() => getUrlParams().toString());
1637
1678
  (0, react.useEffect)(() => {
@@ -1645,6 +1686,8 @@ function ViewMenu({ resourceId, table, fieldsReady = true, selector = "dropdown"
1645
1686
  }, []);
1646
1687
  const columns = table.getAllColumns().filter((column) => column.getCanFilter());
1647
1688
  const keys = table.options.meta?.queryKeys;
1689
+ const viewKey = keys?.view ?? "view";
1690
+ const lastViewParam = (0, react.useRef)(null);
1648
1691
  const readQuery = (0, react.useCallback)(() => {
1649
1692
  const params = getUrlParams();
1650
1693
  return {
@@ -1665,7 +1708,7 @@ function ViewMenu({ resourceId, table, fieldsReady = true, selector = "dropdown"
1665
1708
  if (query.filters.some((filter) => !allowed.has(filter.id)) || query.sort.some((sort) => !table.getColumn(sort.id)?.getCanSort())) throw new Error("视图包含已删除或不可用的字段,请修改视图条件");
1666
1709
  if (query.filters.some((filter) => (table.getColumn(filter.id)?.columnDef.meta?.variant ?? "text") !== filter.variant)) throw new Error("视图中的字段类型已变化,请重新配置筛选条件");
1667
1710
  }, [columns, table]);
1668
- const apply = (0, react.useCallback)((query, history = "push") => {
1711
+ const apply = (0, react.useCallback)((query, history = "push", reference) => {
1669
1712
  validateQuery(query);
1670
1713
  const params = getUrlParams();
1671
1714
  applyReadableFilters(params, columns, query.filters);
@@ -1675,43 +1718,76 @@ function ViewMenu({ resourceId, table, fieldsReady = true, selector = "dropdown"
1675
1718
  if (query.sort.length) params.set(keys?.sort ?? "sort", getSortingStateParser().serialize(query.sort));
1676
1719
  else params.set(keys?.sort ?? "sort", "");
1677
1720
  params.set(keys?.page ?? "page", "1");
1721
+ if (reference !== void 0) {
1722
+ params.set(viewKey, reference);
1723
+ lastViewParam.current = reference;
1724
+ }
1678
1725
  setSearchParams(params, { history });
1679
1726
  onApply?.();
1680
1727
  }, [
1681
1728
  columns,
1682
1729
  keys,
1683
1730
  validateQuery,
1684
- onApply
1731
+ onApply,
1732
+ viewKey
1685
1733
  ]);
1686
1734
  const preferencesRestored = (0, react.useRef)(false);
1687
1735
  (0, react.useEffect)(() => {
1688
- if (preferencesRestored.current || !views.isSuccess || !fieldsReady) return;
1736
+ if (!views.isSuccess || !fieldsReady) return;
1737
+ const first = !preferencesRestored.current;
1738
+ const urlReference = getUrlParams().get(viewKey);
1739
+ if (!first && urlReference === lastViewParam.current) return;
1689
1740
  const savedViews = views.data;
1690
1741
  let cancelled = false;
1691
1742
  queueMicrotask(() => {
1692
- if (cancelled || preferencesRestored.current) return;
1743
+ if (cancelled) return;
1693
1744
  preferencesRestored.current = true;
1745
+ lastViewParam.current = urlReference;
1694
1746
  const preferences = initialPreferences.current;
1695
- if (!preferences || preferences.viewId === null) return;
1696
- const view = savedViews.find((item) => item.id === preferences.viewId);
1747
+ let reference = urlReference;
1748
+ if (first && urlReference === null) reference = preferences ? preferences.viewId : defaultView ?? null;
1749
+ if (reference === null || reference === "") {
1750
+ setSelected(null);
1751
+ setError(null);
1752
+ if (!first || urlReference !== null) writeViewPreferences(config.scope, resourceId, {
1753
+ selector: display,
1754
+ viewId: null
1755
+ });
1756
+ return;
1757
+ }
1758
+ const view = savedViews.find((item) => item.id === reference || item.slug === reference);
1697
1759
  try {
1698
1760
  if (view) {
1699
1761
  validateQuery(view.query);
1700
1762
  const params = getUrlParams();
1701
- if (!(readQuery().filters.length > 0 || [
1763
+ const hasUrlQuery = readQuery().filters.length > 0 || [
1702
1764
  "search",
1703
1765
  keys?.sort ?? "sort",
1704
1766
  keys?.joinOperator ?? "joinOperator",
1705
1767
  keys?.page ?? "page",
1706
1768
  keys?.perPage ?? "perPage"
1707
- ].some((key) => params.has(key)))) apply(view.query, "replace");
1769
+ ].some((key) => params.has(key));
1770
+ const canonical = view.slug ?? view.id;
1771
+ if (!hasUrlQuery) apply(view.query, "replace", canonical);
1772
+ else if (urlReference !== canonical) {
1773
+ params.set(viewKey, canonical);
1774
+ lastViewParam.current = canonical;
1775
+ setSearchParams(params, { history: "replace" });
1776
+ }
1708
1777
  setSelected(view);
1778
+ writeViewPreferences(config.scope, resourceId, {
1779
+ selector: display,
1780
+ viewId: view.id
1781
+ });
1782
+ setError(null);
1709
1783
  return;
1710
1784
  }
1785
+ if (urlReference !== null && urlReference !== "") setError("链接指定的视图不存在或不可用");
1711
1786
  } catch (reason) {
1712
1787
  setError(reason instanceof Error ? reason.message : String(reason));
1713
1788
  }
1714
- writeViewPreferences(config.scope, resourceId, {
1789
+ setSelected(null);
1790
+ if (first && urlReference === null && preferences) writeViewPreferences(config.scope, resourceId, {
1715
1791
  ...preferences,
1716
1792
  viewId: null
1717
1793
  });
@@ -1728,29 +1804,52 @@ function ViewMenu({ resourceId, table, fieldsReady = true, selector = "dropdown"
1728
1804
  keys,
1729
1805
  readQuery,
1730
1806
  apply,
1731
- validateQuery
1807
+ validateQuery,
1808
+ search,
1809
+ viewKey,
1810
+ defaultView,
1811
+ display
1732
1812
  ]);
1733
1813
  const mutation = (0, __tanstack_react_query.useMutation)({
1734
- mutationFn: async ({ operation, input }) => {
1735
- if (operation === "delete") {
1814
+ mutationFn: async (request) => {
1815
+ if (request.operation === "reorder") {
1816
+ if (!storage.reorder) throw new Error("存储服务不支持视图排序");
1817
+ reorderSavedViews(views.data ?? [], request.ids);
1818
+ await storage.reorder(resourceId, request.ids);
1819
+ return null;
1820
+ }
1821
+ if (request.operation === "delete") {
1736
1822
  await storage.remove(resourceId, selected.id);
1737
1823
  return null;
1738
1824
  }
1739
- const valid = viewInputSchema.parse(input);
1740
- return savedViewSchema.parse(operation === "create" ? await storage.create(resourceId, valid) : await storage.update(resourceId, selected.id, valid));
1825
+ const valid = viewInputSchema.parse(request.input);
1826
+ parseSavedViews([...(views.data ?? []).filter((view) => request.operation === "create" || view.id !== selected.id), {
1827
+ ...valid,
1828
+ id: request.operation === "create" ? crypto.randomUUID() : selected.id
1829
+ }]);
1830
+ return savedViewSchema.parse(request.operation === "create" ? await storage.create(resourceId, valid) : await storage.update(resourceId, selected.id, valid));
1741
1831
  },
1742
- onSuccess: async (view) => {
1743
- if (!view) apply({
1744
- filters: [],
1745
- joinOperator: "and",
1746
- search: "",
1747
- sort: []
1748
- });
1749
- setSelected(view);
1750
- writeViewPreferences(config.scope, resourceId, {
1751
- selector: display,
1752
- viewId: view?.id ?? null
1753
- });
1832
+ onSuccess: async (view, request) => {
1833
+ if (request.operation !== "reorder") {
1834
+ if (!view) apply({
1835
+ filters: [],
1836
+ joinOperator: "and",
1837
+ search: "",
1838
+ sort: []
1839
+ }, "push", "");
1840
+ else {
1841
+ const reference = view.slug ?? view.id;
1842
+ const params = getUrlParams();
1843
+ params.set(viewKey, reference);
1844
+ lastViewParam.current = reference;
1845
+ setSearchParams(params, { history: "replace" });
1846
+ }
1847
+ setSelected(view);
1848
+ writeViewPreferences(config.scope, resourceId, {
1849
+ selector: display,
1850
+ viewId: view?.id ?? null
1851
+ });
1852
+ }
1754
1853
  setDialog(null);
1755
1854
  setError(null);
1756
1855
  await client.invalidateQueries({ queryKey });
@@ -1764,7 +1863,7 @@ function ViewMenu({ resourceId, table, fieldsReady = true, selector = "dropdown"
1764
1863
  joinOperator: "and",
1765
1864
  search: "",
1766
1865
  sort: []
1767
- });
1866
+ }, "push", view?.slug ?? view?.id ?? "");
1768
1867
  setSelected(view);
1769
1868
  writeViewPreferences(config.scope, resourceId, {
1770
1869
  selector: display,
@@ -1777,6 +1876,11 @@ function ViewMenu({ resourceId, table, fieldsReady = true, selector = "dropdown"
1777
1876
  };
1778
1877
  const open = (operation) => {
1779
1878
  setName(operation === "create" ? "" : selected?.name ?? "");
1879
+ let nextSlug = "";
1880
+ if (operation === "rename") nextSlug = selected?.slug ?? "";
1881
+ else if (operation === "duplicate") nextSlug = suggestSlug(selected?.name ?? "", views.data ?? []);
1882
+ setSlug(nextSlug);
1883
+ if (operation === "reorder") setOrderedViews(views.data ?? []);
1780
1884
  setError(null);
1781
1885
  setDialog(operation);
1782
1886
  };
@@ -1891,6 +1995,11 @@ function ViewMenu({ resourceId, table, fieldsReady = true, selector = "dropdown"
1891
1995
  })
1892
1996
  ] }),
1893
1997
  selected && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(__wordrhyme_shadcn.DropdownMenuSeparator, {}),
1998
+ storage.reorder && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(__wordrhyme_shadcn.DropdownMenuItem, {
1999
+ disabled: views.isError || (views.data?.length ?? 0) < 2,
2000
+ onSelect: () => open("reorder"),
2001
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(lucide_react.ArrowDownUp, { className: "size-4" }), "调整顺序"]
2002
+ }),
1894
2003
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(__wordrhyme_shadcn.DropdownMenuSub, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)(__wordrhyme_shadcn.DropdownMenuSubTrigger, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(lucide_react.PanelsTopLeft, { className: "size-4" }), "展示方式"] }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(__wordrhyme_shadcn.DropdownMenuSubContent, { children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(__wordrhyme_shadcn.DropdownMenuRadioGroup, {
1895
2004
  value: display,
1896
2005
  onValueChange: (value) => {
@@ -1921,6 +2030,7 @@ function ViewMenu({ resourceId, table, fieldsReady = true, selector = "dropdown"
1921
2030
  operation: "update",
1922
2031
  input: {
1923
2032
  name: selected.name,
2033
+ slug: selected.slug,
1924
2034
  query: current
1925
2035
  }
1926
2036
  }),
@@ -1940,6 +2050,7 @@ function ViewMenu({ resourceId, table, fieldsReady = true, selector = "dropdown"
1940
2050
  variant: "link",
1941
2051
  size: "sm",
1942
2052
  onClick: () => {
2053
+ preferencesRestored.current = false;
1943
2054
  views.refetch().catch((reason) => setError(reason instanceof Error ? reason.message : String(reason)));
1944
2055
  },
1945
2056
  children: "重试"
@@ -1968,17 +2079,30 @@ function ViewMenu({ resourceId, table, fieldsReady = true, selector = "dropdown"
1968
2079
  delete: "删除视图",
1969
2080
  rename: "重命名视图",
1970
2081
  create: "另存为新视图",
1971
- duplicate: "复制视图"
2082
+ duplicate: "复制视图",
2083
+ reorder: "调整视图顺序"
1972
2084
  }[dialog] }) }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("form", {
1973
2085
  onSubmit: (event) => {
1974
2086
  event.preventDefault();
2087
+ if (mutation.isPending) return;
2088
+ if (dialog === "reorder") {
2089
+ mutation.mutate({
2090
+ operation: "reorder",
2091
+ ids: orderedViews.map((view) => view.id)
2092
+ });
2093
+ return;
2094
+ }
2095
+ if (dialog === "delete") {
2096
+ mutation.mutate({ operation: "delete" });
2097
+ return;
2098
+ }
1975
2099
  let operation = "create";
1976
- if (dialog === "delete") operation = "delete";
1977
- else if (dialog === "rename") operation = "update";
2100
+ if (dialog === "rename") operation = "update";
1978
2101
  mutation.mutate({
1979
2102
  operation,
1980
2103
  input: {
1981
2104
  name,
2105
+ slug: slug.trim() || void 0,
1982
2106
  query: dialog === "rename" || dialog === "duplicate" ? selected.query : current
1983
2107
  }
1984
2108
  });
@@ -1988,14 +2112,68 @@ function ViewMenu({ resourceId, table, fieldsReady = true, selector = "dropdown"
1988
2112
  "删除「",
1989
2113
  selected?.name,
1990
2114
  "」?数据和标签会保留。"
1991
- ] }) : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
1992
- className: "grid gap-2",
1993
- children: ["视图名称", /* @__PURE__ */ (0, react_jsx_runtime.jsx)(__wordrhyme_shadcn.Input, {
1994
- value: name,
1995
- onChange: (event) => setName(event.target.value),
1996
- maxLength: 40,
1997
- required: true,
1998
- autoFocus: true
2115
+ ] }) : dialog === "reorder" ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2116
+ className: "grid gap-3",
2117
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
2118
+ className: "text-sm text-muted-foreground",
2119
+ children: "拖动手柄调整顺序,“全部”固定在最前。保存后生效。"
2120
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(__wordrhyme_shadcn_ui.Sortable, {
2121
+ value: orderedViews,
2122
+ onValueChange: setOrderedViews,
2123
+ getItemValue: (view) => view.id,
2124
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(__wordrhyme_shadcn_ui.SortableContent, {
2125
+ className: "grid max-h-80 gap-2 overflow-y-auto",
2126
+ children: orderedViews.map((view) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(__wordrhyme_shadcn_ui.SortableItem, {
2127
+ value: view.id,
2128
+ disabled: mutation.isPending,
2129
+ className: "flex items-center gap-2 rounded-md border bg-background p-2",
2130
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(__wordrhyme_shadcn_ui.SortableItemHandle, {
2131
+ asChild: true,
2132
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(__wordrhyme_shadcn.Button, {
2133
+ type: "button",
2134
+ variant: "ghost",
2135
+ size: "icon-sm",
2136
+ "aria-label": `拖动排序:${view.name}`,
2137
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(lucide_react.GripVertical, { className: "size-4" })
2138
+ })
2139
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2140
+ className: "min-w-0 truncate",
2141
+ children: view.name
2142
+ })]
2143
+ }, view.id))
2144
+ })
2145
+ })]
2146
+ }) : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2147
+ className: "grid gap-4",
2148
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
2149
+ className: "grid gap-2",
2150
+ children: ["视图名称", /* @__PURE__ */ (0, react_jsx_runtime.jsx)(__wordrhyme_shadcn.Input, {
2151
+ value: name,
2152
+ onChange: (event) => {
2153
+ const { value } = event.target;
2154
+ if (dialog !== "rename" && slug === suggestSlug(name, views.data ?? [])) setSlug(suggestSlug(value, views.data ?? []));
2155
+ setName(value);
2156
+ },
2157
+ maxLength: 40,
2158
+ required: true,
2159
+ autoFocus: true
2160
+ })]
2161
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
2162
+ className: "grid gap-2",
2163
+ children: [
2164
+ "链接标识(slug)",
2165
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(__wordrhyme_shadcn.Input, {
2166
+ value: slug,
2167
+ onChange: (event) => setSlug(event.target.value),
2168
+ maxLength: 80,
2169
+ required: dialog === "rename" && selected?.slug !== void 0,
2170
+ placeholder: "例如 vip-customers"
2171
+ }),
2172
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2173
+ className: "text-sm text-muted-foreground",
2174
+ children: "用于分享链接,同一列表内唯一。修改后旧链接将失效。"
2175
+ })
2176
+ ]
1999
2177
  })]
2000
2178
  }),
2001
2179
  error !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
@@ -7293,6 +7471,7 @@ function useDataTable(props) {
7293
7471
  meta: {
7294
7472
  ...tableProps.meta,
7295
7473
  queryKeys: {
7474
+ view: queryKeys?.view ?? "view",
7296
7475
  page: pageKey,
7297
7476
  perPage: perPageKey,
7298
7477
  sort: sortKey,
@@ -7657,6 +7836,7 @@ function AutoTable({ resourceId, views, schema, data, getRowId, expandedRow, def
7657
7836
  table,
7658
7837
  onApply: clearBatchSearch,
7659
7838
  fieldsReady: typeof views === "object" ? views.fieldsReady : void 0,
7839
+ defaultView: typeof views === "object" ? views.defaultView : void 0,
7660
7840
  selector: typeof views === "object" ? views.selector : void 0,
7661
7841
  tabs: typeof views === "object" ? views.tabs : void 0,
7662
7842
  counts: typeof views === "object" ? views.counts : void 0,
package/dist/index.d.cts CHANGED
@@ -750,6 +750,8 @@ declare module '@tanstack/react-table' {
750
750
  }
751
751
  }
752
752
  interface QueryKeys {
753
+ /** Saved view slug or ID. Defaults to "view". */
754
+ view?: string;
753
755
  page: string;
754
756
  perPage: string;
755
757
  sort: string;
@@ -999,6 +1001,8 @@ interface ViewStorage {
999
1001
  create: (resourceId: string, input: ViewInput) => Promise<SavedView>;
1000
1002
  update: (resourceId: string, id: string, input: ViewInput) => Promise<SavedView>;
1001
1003
  remove: (resourceId: string, id: string) => Promise<void>;
1004
+ /** Atomically persist all view IDs in order; reject duplicates, missing or unknown IDs. */
1005
+ reorder?: (resourceId: string, ids: string[]) => Promise<void>;
1002
1006
  }
1003
1007
  interface TagScope {
1004
1008
  resourceId: string;
@@ -1106,6 +1110,7 @@ declare const viewQuerySchema: z.ZodObject<{
1106
1110
  }, z.core.$strip>;
1107
1111
  declare const viewInputSchema: z.ZodObject<{
1108
1112
  name: z.ZodString;
1113
+ slug: z.ZodOptional<z.ZodString>;
1109
1114
  query: z.ZodObject<{
1110
1115
  filters: z.ZodArray<z.ZodUnion<readonly [z.ZodObject<{
1111
1116
  id: z.ZodString;
@@ -1164,6 +1169,7 @@ declare const viewInputSchema: z.ZodObject<{
1164
1169
  }, z.core.$strip>;
1165
1170
  declare const savedViewSchema: z.ZodObject<{
1166
1171
  name: z.ZodString;
1172
+ slug: z.ZodOptional<z.ZodString>;
1167
1173
  query: z.ZodObject<{
1168
1174
  filters: z.ZodArray<z.ZodUnion<readonly [z.ZodObject<{
1169
1175
  id: z.ZodString;
@@ -1237,6 +1243,8 @@ declare function createLocalViewStorage(namespace: string): ViewStorage;
1237
1243
  //#endregion
1238
1244
  //#region src/components/auto-crud/saved-views.d.ts
1239
1245
  interface SavedViewsConfig {
1246
+ /** Initial view slug or ID when the URL and local preferences do not select a view. */
1247
+ defaultView?: string;
1240
1248
  /** Wait for asynchronous field metadata before restoring or selecting a view. */
1241
1249
  fieldsReady?: boolean;
1242
1250
  /** Initial display mode when no local preference exists. */
package/dist/index.d.ts CHANGED
@@ -5,9 +5,9 @@ import * as react_jsx_runtime17 from "react/jsx-runtime";
5
5
  import { DropdownMenuTrigger, PopoverContentProps } from "@wordrhyme/shadcn";
6
6
  import { z } from "zod";
7
7
  import { ClassValue } from "clsx";
8
+ import { MultiCombobox, MultiComboboxOption, MultiComboboxProps, MultiComboboxTriggerRenderProps, Select, SelectMode, SelectOption, SelectProps, SelectSearchableDynamicProps, SelectSearchableMultipleProps, SelectSearchableProps, SelectSearchableSingleProps, SelectSimpleProps, SelectTriggerRenderProps } from "@wordrhyme/shadcn-ui";
8
9
  import * as _tanstack_react_table0 from "@tanstack/react-table";
9
10
  import { Column, ColumnDef, ColumnMeta, ColumnSort, Row, RowData, Table as Table$1, TableOptions, TableState, VisibilityState } from "@tanstack/react-table";
10
- import { MultiCombobox, MultiComboboxOption, MultiComboboxProps, MultiComboboxTriggerRenderProps, Select, SelectMode, SelectOption, SelectProps, SelectSearchableDynamicProps, SelectSearchableMultipleProps, SelectSearchableProps, SelectSearchableSingleProps, SelectSimpleProps, SelectTriggerRenderProps } from "@wordrhyme/shadcn-ui";
11
11
  import { DropdownMenuItem as DropdownMenuItem$1 } from "@wordrhyme/ui";
12
12
  import { JsonSchemaFormScope } from "@wordrhyme/formily-shadcn";
13
13
  import { ISchema } from "@formily/json-schema";
@@ -750,6 +750,8 @@ declare module '@tanstack/react-table' {
750
750
  }
751
751
  }
752
752
  interface QueryKeys {
753
+ /** Saved view slug or ID. Defaults to "view". */
754
+ view?: string;
753
755
  page: string;
754
756
  perPage: string;
755
757
  sort: string;
@@ -999,6 +1001,8 @@ interface ViewStorage {
999
1001
  create: (resourceId: string, input: ViewInput) => Promise<SavedView>;
1000
1002
  update: (resourceId: string, id: string, input: ViewInput) => Promise<SavedView>;
1001
1003
  remove: (resourceId: string, id: string) => Promise<void>;
1004
+ /** Atomically persist all view IDs in order; reject duplicates, missing or unknown IDs. */
1005
+ reorder?: (resourceId: string, ids: string[]) => Promise<void>;
1002
1006
  }
1003
1007
  interface TagScope {
1004
1008
  resourceId: string;
@@ -1106,6 +1110,7 @@ declare const viewQuerySchema: z.ZodObject<{
1106
1110
  }, z.core.$strip>;
1107
1111
  declare const viewInputSchema: z.ZodObject<{
1108
1112
  name: z.ZodString;
1113
+ slug: z.ZodOptional<z.ZodString>;
1109
1114
  query: z.ZodObject<{
1110
1115
  filters: z.ZodArray<z.ZodUnion<readonly [z.ZodObject<{
1111
1116
  id: z.ZodString;
@@ -1164,6 +1169,7 @@ declare const viewInputSchema: z.ZodObject<{
1164
1169
  }, z.core.$strip>;
1165
1170
  declare const savedViewSchema: z.ZodObject<{
1166
1171
  name: z.ZodString;
1172
+ slug: z.ZodOptional<z.ZodString>;
1167
1173
  query: z.ZodObject<{
1168
1174
  filters: z.ZodArray<z.ZodUnion<readonly [z.ZodObject<{
1169
1175
  id: z.ZodString;
@@ -1237,6 +1243,8 @@ declare function createLocalViewStorage(namespace: string): ViewStorage;
1237
1243
  //#endregion
1238
1244
  //#region src/components/auto-crud/saved-views.d.ts
1239
1245
  interface SavedViewsConfig {
1246
+ /** Initial view slug or ID when the URL and local preferences do not select a view. */
1247
+ defaultView?: string;
1240
1248
  /** Wait for asynchronous field metadata before restoring or selecting a view. */
1241
1249
  fieldsReady?: boolean;
1242
1250
  /** Initial display mode when no local preference exists. */