chore: release 0.0.1

This commit is contained in:
liaibo
2025-11-29 05:16:23 +08:00
parent 5d0e4c4cb6
commit d350b02fba
92 changed files with 78929 additions and 39164 deletions
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 182 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 181 KiB

+5
View File
@@ -0,0 +1,5 @@
npm error Missing script: "dev"
npm error
npm error To see a list of scripts, run:
npm error npm run
npm error A complete log of this run can be found in: C:\Users\liaib\AppData\Local\npm-cache\_logs\2025-11-26T12_11_38_138Z-debug-0.log
+106
View File
@@ -407,4 +407,110 @@
.wolai-media__resize-handle.is-dragging {
background: rgba(37, 99, 235, 0.85);
}
.online-table-block {
position: relative;
}
.online-table-block:hover .wolai-table-resize-handle,
.online-table-block:focus-within .wolai-table-resize-handle {
opacity: 1;
}
.wolai-table-resize-handle {
position: absolute;
background: rgba(15, 23, 42, 0.45);
border-radius: 999px;
transition: opacity 0.15s ease, background 0.2s ease;
opacity: 0;
z-index: 40;
}
.wolai-table-resize-handle.is-dragging {
background: rgba(37, 99, 235, 0.9);
}
.wolai-table-resize-handle--left,
.wolai-table-resize-handle--right {
top: 50%;
width: 12px;
height: 48px;
transform: translateY(-50%);
cursor: ew-resize;
}
.wolai-table-resize-handle--left {
left: -6px;
}
.wolai-table-resize-handle--right {
right: -6px;
}
.wolai-table-resize-handle--top,
.wolai-table-resize-handle--bottom {
left: 50%;
width: 48px;
height: 12px;
transform: translateX(-50%);
cursor: ns-resize;
}
.wolai-table-resize-handle--top {
top: -6px;
}
.wolai-table-resize-handle--bottom {
bottom: -6px;
}
.wolai-table-resize-handle--top-left,
.wolai-table-resize-handle--top-right,
.wolai-table-resize-handle--bottom-left,
.wolai-table-resize-handle--bottom-right {
width: 16px;
height: 16px;
border-radius: 4px;
}
.wolai-table-resize-handle--top-left {
top: -8px;
left: -8px;
cursor: nwse-resize;
}
.wolai-table-resize-handle--bottom-right {
bottom: -8px;
right: -8px;
cursor: nwse-resize;
}
.wolai-table-resize-handle--top-right {
top: -8px;
right: -8px;
cursor: nesw-resize;
}
.wolai-table-resize-handle--bottom-left {
bottom: -8px;
left: -8px;
cursor: nesw-resize;
}
.luckysheet-input-box {
opacity: 1 !important;
pointer-events: auto !important;
}
#luckysheet-rich-text-editor {
min-height: 24px;
font-size: 16px;
caret-color: #2563eb;
color: #111827;
}
#luckysheet-rich-text-editor:focus {
outline: 2px solid rgba(37, 99, 235, 0.2);
outline-offset: 2px;
}
}
@@ -0,0 +1,15 @@
alter table public.document_tables
add column if not exists grid_key uuid;
update public.document_tables
set grid_key = gen_random_uuid()
where grid_key is null;
alter table public.document_tables
alter column grid_key set not null;
alter table public.document_tables
alter column grid_key set default gen_random_uuid();
create unique index if not exists document_tables_grid_key_idx
on public.document_tables (grid_key);
Binary file not shown.

After

Width:  |  Height:  |  Size: 158 KiB

+63
View File
@@ -0,0 +1,63 @@
import { PostgresMeta } from '../../lib/index.js';
import * as Parser from '../../lib/Parser.js';
import { createConnectionConfig, extractRequestForLogging, translateErrorToResponseCode, } from '../utils.js';
const errorOnEmptyQuery = (request) => {
if (!request.body.query) {
throw new Error('query not found');
}
};
export default async (fastify) => {
fastify.post('/', async (request, reply) => {
const statementTimeoutSecs = request.query.statementTimeoutSecs;
errorOnEmptyQuery(request);
const config = createConnectionConfig(request);
const pgMeta = new PostgresMeta(config);
request.log.info({
message: 'pg-meta query payload',
body: request.body,
});
const pgParameters = request.body.parameters ?? request.body.params;
const { data, error } = await pgMeta.query(request.body.query, {
trackQueryInSentry: true,
statementQueryTimeout: statementTimeoutSecs,
parameters: pgParameters,
});
await pgMeta.end();
if (error) {
request.log.error({ error, request: extractRequestForLogging(request) });
reply.code(translateErrorToResponseCode(error));
return { error: error.formattedError ?? error.message, ...error };
}
return data || [];
});
fastify.post('/format', async (request, reply) => {
errorOnEmptyQuery(request);
const { data, error } = await Parser.Format(request.body.query);
if (error) {
request.log.error({ error, request: extractRequestForLogging(request) });
reply.code(translateErrorToResponseCode(error));
return { error: error.message };
}
return data;
});
fastify.post('/parse', async (request, reply) => {
errorOnEmptyQuery(request);
const { data, error } = Parser.Parse(request.body.query);
if (error) {
request.log.error({ error, request: extractRequestForLogging(request) });
reply.code(translateErrorToResponseCode(error));
return { error: error.message };
}
return data;
});
fastify.post('/deparse', async (request, reply) => {
const { data, error } = Parser.Deparse(request.body.ast);
if (error) {
request.log.error({ error, request: extractRequestForLogging(request) });
reply.code(translateErrorToResponseCode(error));
return { error: error.message };
}
return data;
});
};
//# sourceMappingURL=query.js.map
Binary file not shown.

Before

Width:  |  Height:  |  Size: 196 KiB

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 139 KiB

After

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 134 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 104 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 270 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 243 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 209 KiB

+7 -1
View File
@@ -10,7 +10,13 @@ app = FastAPI(title="Wolai Backend", version="0.1.0-stage0")
app.add_middleware(
CORSMiddleware,
allow_origins=[settings.frontend_url, "http://localhost:3000", "http://localhost:3001"],
allow_origins=[
settings.frontend_url,
"http://localhost:3000",
"http://localhost:3001",
"http://127.0.0.1:3000",
"http://127.0.0.1:3001",
],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
+2 -1
View File
@@ -1,6 +1,6 @@
from fastapi import APIRouter
from . import chat, health, tasks
from . import chat, health, luckysheet_ws, tasks
api_router = APIRouter(prefix="/api/v1")
api_router.include_router(tasks.router, tags=["tasks"])
@@ -8,3 +8,4 @@ api_router.include_router(chat.router, tags=["chat"])
root_router = APIRouter()
root_router.include_router(health.router, tags=["health"])
root_router.include_router(luckysheet_ws.router)
+195
View File
@@ -0,0 +1,195 @@
from __future__ import annotations
import asyncio
import json
import logging
import urllib.parse
import zlib
from dataclasses import dataclass
from typing import Dict, List, Optional
import httpx
from fastapi import APIRouter, WebSocket, WebSocketDisconnect, status
from starlette.concurrency import run_in_threadpool
from app.config import settings
from app.services.supabase_rest import supabase_rest
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/ws", tags=["luckysheet"])
@dataclass
class LuckysheetClient:
websocket: WebSocket
user_id: str
username: str
class LuckysheetConnectionManager:
def __init__(self) -> None:
self._clients: Dict[str, List[LuckysheetClient]] = {}
self._lock = asyncio.Lock()
async def add(self, grid_key: str, client: LuckysheetClient) -> None:
async with self._lock:
self._clients.setdefault(grid_key, []).append(client)
logger.debug("Luckysheet client %s joined grid %s", client.user_id, grid_key)
async def remove(self, grid_key: str, websocket: WebSocket) -> None:
async with self._lock:
clients = self._clients.get(grid_key)
if not clients:
return
self._clients[grid_key] = [client for client in clients if client.websocket is not websocket]
if not self._clients[grid_key]:
self._clients.pop(grid_key, None)
logger.debug("Luckysheet connection removed from grid %s", grid_key)
async def broadcast_payload(
self,
grid_key: str,
sender: LuckysheetClient,
payload: str,
event_type: int,
) -> None:
clients = self._clients.get(grid_key, [])
if not clients:
return
message = json.dumps(
{
"data": payload,
"id": sender.user_id,
"username": sender.username,
"type": event_type,
},
ensure_ascii=False,
)
for client in clients:
if client.websocket is sender.websocket:
continue
try:
await client.websocket.send_text(message)
except Exception as exc:
logger.warning("Failed to forward message to %s: %s", client.user_id, exc)
async def broadcast_exit(self, grid_key: str, user_id: str) -> None:
clients = self._clients.get(grid_key, [])
if not clients:
return
message = json.dumps({"message": "用户退出", "id": user_id}, ensure_ascii=False)
for client in clients:
try:
await client.websocket.send_text(message)
except Exception as exc:
logger.warning("Failed to notify client exit: %s", exc)
manager = LuckysheetConnectionManager()
def _decode_ws_payload(raw_message: str) -> Optional[dict]:
try:
compressed = raw_message.encode("latin1")
inflated = zlib.decompress(compressed)
decoded = urllib.parse.unquote(inflated.decode("utf-8"))
return json.loads(decoded)
except Exception as exc:
logger.debug("Failed to decode luckysheet payload: %s", exc)
return None
async def _fetch_supabase_user(access_token: str) -> Optional[dict]:
base_url = settings.supabase_url.rstrip("/")
headers = {
"apikey": settings.supabase_service_role_key,
"Authorization": f"Bearer {access_token}",
}
try:
async with httpx.AsyncClient(timeout=5.0) as client:
response = await client.get(f"{base_url}/auth/v1/user", headers=headers)
response.raise_for_status()
return response.json()
except httpx.HTTPError as exc:
logger.warning("Supabase auth verification failed: %s", exc)
return None
async def _fetch_table_by_grid_key(grid_key: str) -> Optional[dict]:
try:
return await run_in_threadpool(lambda: supabase_rest.select_one("document_tables", {"grid_key": grid_key}))
except Exception as exc:
logger.error("Failed to query document_tables: %s", exc)
return None
async def _is_workspace_member(workspace_id: str, user_id: str) -> bool:
try:
result = await run_in_threadpool(
lambda: supabase_rest.select_one("workspace_members", {"workspace_id": workspace_id, "user_id": user_id}),
)
return bool(result)
except Exception as exc:
logger.error("Failed to verify workspace membership: %s", exc)
return False
@router.websocket("/luckysheet")
async def luckysheet_collaboration(websocket: WebSocket) -> None:
params = websocket.query_params
grid_key = params.get("gridKey")
token = params.get("token")
raw_user_id = params.get("userid") or params.get("userId")
requested_type = params.get("type") or "luckysheet"
username = params.get("username") or ""
if requested_type != "luckysheet" or not grid_key or not token or not raw_user_id:
await websocket.close(code=status.WS_1008_POLICY_VIOLATION, reason="缺少协同参数")
return
supabase_user = await _fetch_supabase_user(token)
if not supabase_user or supabase_user.get("id") != raw_user_id:
await websocket.close(code=status.WS_1008_POLICY_VIOLATION, reason="身份验证失败")
return
table_row = await _fetch_table_by_grid_key(grid_key)
if not table_row or not table_row.get("workspace_id"):
await websocket.close(code=status.WS_1008_POLICY_VIOLATION, reason="gridKey 无效")
return
workspace_id = table_row["workspace_id"]
if not await _is_workspace_member(workspace_id, raw_user_id):
await websocket.close(code=status.WS_1008_POLICY_VIOLATION, reason="无权访问该表格")
return
fallback_name = (
username
or supabase_user.get("user_metadata", {}).get("full_name")
or supabase_user.get("email")
or f"用户-{raw_user_id[:6]}"
)
client = LuckysheetClient(websocket=websocket, user_id=raw_user_id, username=fallback_name)
await websocket.accept()
await manager.add(grid_key, client)
logger.info("Luckysheet client %s connected to %s", raw_user_id, grid_key)
try:
while True:
message = await websocket.receive_text()
if message == "rub":
continue
decoded = _decode_ws_payload(message)
if not decoded:
continue
op_type = decoded.get("t")
event_type = 3 if op_type == "mv" else 2
await manager.broadcast_payload(grid_key, client, message, event_type)
except WebSocketDisconnect:
logger.info("Luckysheet client %s disconnected", raw_user_id)
except Exception as exc:
logger.error("Luckysheet websocket error: %s", exc)
finally:
await manager.remove(grid_key, websocket)
await manager.broadcast_exit(grid_key, raw_user_id)
+4
View File
@@ -0,0 +1,4 @@
INFO: Started server process [27148]
INFO: Waiting for application startup.
INFO: Application startup complete.
INFO: Uvicorn running on http://0.0.0.0:8000 (Press CTRL+C to quit)
View File
+103
View File
@@ -0,0 +1,103 @@
⚠ Warning: Next.js inferred your workspace root, but it may not be correct.
We detected multiple lockfiles and selected the directory of F:\SOFT\MNOTE\pnpm-lock.yaml as the root directory.
To silence this warning, set `turbopack.root` in your Next.js config, or consider removing one of the lockfiles if it's not needed.
See https://nextjs.org/docs/app/api-reference/config/next-config-js/turbopack#root-directory for more information.
Detected additional lockfiles:
* F:\SOFT\MNOTE\wolai-frontend\pnpm-lock.yaml
F:\SOFT\MNOTE\wolai-frontend\.next\dev\server\chunks\ssr\012f5__pnpm_791fff69._.js: Invalid source map. Only conformant source maps can be used to find the original code. Cause: Error: sourceMapURL could not be parsed
Error [AuthApiError]: Invalid Refresh Token: Refresh Token Not Found
at handleError (F:\SOFT\MNOTE\wolai-frontend\.next\dev\server\chunks\ssr\012f5__pnpm_791fff69._.js:8492:11)
at async _handleRequest (F:\SOFT\MNOTE\wolai-frontend\.next\dev\server\chunks\ssr\012f5__pnpm_791fff69._.js:8542:9)
at async _request (F:\SOFT\MNOTE\wolai-frontend\.next\dev\server\chunks\ssr\012f5__pnpm_791fff69._.js:8522:18)
at async (F:\SOFT\MNOTE\wolai-frontend\.next\dev\server\chunks\ssr\012f5__pnpm_791fff69._.js:12357:24)
at async (F:\SOFT\MNOTE\wolai-frontend\.next\dev\server\chunks\ssr\012f5__pnpm_791fff69._.js:8251:36) {
__isAuthError: true,
status: 400,
code: 'refresh_token_not_found'
}
F:\SOFT\MNOTE\wolai-frontend\.next\dev\server\chunks\ssr\012f5__pnpm_791fff69._.js: Invalid source map. Only conformant source maps can be used to find the original code. Cause: Error: sourceMapURL could not be parsed
Error [AuthApiError]: Invalid Refresh Token: Refresh Token Not Found
at handleError (F:\SOFT\MNOTE\wolai-frontend\.next\dev\server\chunks\ssr\012f5__pnpm_791fff69._.js:8492:11)
at async _handleRequest (F:\SOFT\MNOTE\wolai-frontend\.next\dev\server\chunks\ssr\012f5__pnpm_791fff69._.js:8542:9)
at async _request (F:\SOFT\MNOTE\wolai-frontend\.next\dev\server\chunks\ssr\012f5__pnpm_791fff69._.js:8522:18)
at async (F:\SOFT\MNOTE\wolai-frontend\.next\dev\server\chunks\ssr\012f5__pnpm_791fff69._.js:12357:24)
at async (F:\SOFT\MNOTE\wolai-frontend\.next\dev\server\chunks\ssr\012f5__pnpm_791fff69._.js:8251:36) {
__isAuthError: true,
status: 400,
code: 'refresh_token_not_found'
}
F:\SOFT\MNOTE\wolai-frontend\.next\dev\server\chunks\ssr\012f5__pnpm_791fff69._.js: Invalid source map. Only conformant source maps can be used to find the original code. Cause: Error: sourceMapURL could not be parsed
Error [AuthApiError]: Invalid Refresh Token: Refresh Token Not Found
at handleError (F:\SOFT\MNOTE\wolai-frontend\.next\dev\server\chunks\ssr\012f5__pnpm_791fff69._.js:8492:11)
at async _handleRequest (F:\SOFT\MNOTE\wolai-frontend\.next\dev\server\chunks\ssr\012f5__pnpm_791fff69._.js:8542:9)
at async _request (F:\SOFT\MNOTE\wolai-frontend\.next\dev\server\chunks\ssr\012f5__pnpm_791fff69._.js:8522:18)
at async (F:\SOFT\MNOTE\wolai-frontend\.next\dev\server\chunks\ssr\012f5__pnpm_791fff69._.js:12357:24)
at async (F:\SOFT\MNOTE\wolai-frontend\.next\dev\server\chunks\ssr\012f5__pnpm_791fff69._.js:8251:36) {
__isAuthError: true,
status: 400,
code: 'refresh_token_not_found'
}
F:\SOFT\MNOTE\wolai-frontend\.next\dev\server\chunks\ssr\012f5__pnpm_791fff69._.js: Invalid source map. Only conformant source maps can be used to find the original code. Cause: Error: sourceMapURL could not be parsed
Error [AuthApiError]: Invalid Refresh Token: Refresh Token Not Found
at handleError (F:\SOFT\MNOTE\wolai-frontend\.next\dev\server\chunks\ssr\012f5__pnpm_791fff69._.js:8492:11)
at async _handleRequest (F:\SOFT\MNOTE\wolai-frontend\.next\dev\server\chunks\ssr\012f5__pnpm_791fff69._.js:8542:9)
at async _request (F:\SOFT\MNOTE\wolai-frontend\.next\dev\server\chunks\ssr\012f5__pnpm_791fff69._.js:8522:18)
at async (F:\SOFT\MNOTE\wolai-frontend\.next\dev\server\chunks\ssr\012f5__pnpm_791fff69._.js:12357:24)
at async (F:\SOFT\MNOTE\wolai-frontend\.next\dev\server\chunks\ssr\012f5__pnpm_791fff69._.js:8251:36) {
__isAuthError: true,
status: 400,
code: 'refresh_token_not_found'
}
Using the user object as returned from supabase.auth.getSession() or from some supabase.auth.onAuthStateChange() events could be insecure! This value comes directly from the storage medium (usually cookies on the server) and may not be authentic. Use supabase.auth.getUser() instead which authenticates the data by contacting the Supabase Auth server.
F:\SOFT\MNOTE\wolai-frontend\.next\dev\server\chunks\ssr\wolai-frontend_9f844c81._.js: Invalid source map. Only conformant source maps can be used to find the original code. Cause: Error: sourceMapURL could not be parsed
F:\SOFT\MNOTE\wolai-frontend\.next\dev\server\chunks\ssr\[root-of-the-server]__97b7743c._.js: Invalid source map. Only conformant source maps can be used to find the original code. Cause: Error: sourceMapURL could not be parsed
⨯ Error: 创建默认工作空间失败:new row violates row-level security policy for table "workspaces"
at ensureDefaultWorkspace (F:\SOFT\MNOTE\wolai-frontend\.next\dev\server\chunks\ssr\wolai-frontend_9f844c81._.js:172:15)
at async Home (F:\SOFT\MNOTE\wolai-frontend\.next\dev\server\chunks\ssr\[root-of-the-server]__97b7743c._.js:126:5) {
digest: '2664484505'
}
Using the user object as returned from supabase.auth.getSession() or from some supabase.auth.onAuthStateChange() events could be insecure! This value comes directly from the storage medium (usually cookies on the server) and may not be authentic. Use supabase.auth.getUser() instead which authenticates the data by contacting the Supabase Auth server.
Using the user object as returned from supabase.auth.getSession() or from some supabase.auth.onAuthStateChange() events could be insecure! This value comes directly from the storage medium (usually cookies on the server) and may not be authentic. Use supabase.auth.getUser() instead which authenticates the data by contacting the Supabase Auth server.
F:\SOFT\MNOTE\wolai-frontend\.next\dev\server\chunks\ssr\wolai-frontend_9f844c81._.js: Invalid source map. Only conformant source maps can be used to find the original code. Cause: Error: sourceMapURL could not be parsed
F:\SOFT\MNOTE\wolai-frontend\.next\dev\server\chunks\ssr\[root-of-the-server]__97b7743c._.js: Invalid source map. Only conformant source maps can be used to find the original code. Cause: Error: sourceMapURL could not be parsed
⨯ Error: 创建默认工作空间失败:new row violates row-level security policy for table "workspaces"
at ensureDefaultWorkspace (F:\SOFT\MNOTE\wolai-frontend\.next\dev\server\chunks\ssr\wolai-frontend_9f844c81._.js:172:15)
at async Home (F:\SOFT\MNOTE\wolai-frontend\.next\dev\server\chunks\ssr\[root-of-the-server]__97b7743c._.js:126:5) {
digest: '2664484505'
}
Using the user object as returned from supabase.auth.getSession() or from some supabase.auth.onAuthStateChange() events could be insecure! This value comes directly from the storage medium (usually cookies on the server) and may not be authentic. Use supabase.auth.getUser() instead which authenticates the data by contacting the Supabase Auth server.
Using the user object as returned from supabase.auth.getSession() or from some supabase.auth.onAuthStateChange() events could be insecure! This value comes directly from the storage medium (usually cookies on the server) and may not be authentic. Use supabase.auth.getUser() instead which authenticates the data by contacting the Supabase Auth server.
Using the user object as returned from supabase.auth.getSession() or from some supabase.auth.onAuthStateChange() events could be insecure! This value comes directly from the storage medium (usually cookies on the server) and may not be authentic. Use supabase.auth.getUser() instead which authenticates the data by contacting the Supabase Auth server.
Using the user object as returned from supabase.auth.getSession() or from some supabase.auth.onAuthStateChange() events could be insecure! This value comes directly from the storage medium (usually cookies on the server) and may not be authentic. Use supabase.auth.getUser() instead which authenticates the data by contacting the Supabase Auth server.
Using the user object as returned from supabase.auth.getSession() or from some supabase.auth.onAuthStateChange() events could be insecure! This value comes directly from the storage medium (usually cookies on the server) and may not be authentic. Use supabase.auth.getUser() instead which authenticates the data by contacting the Supabase Auth server.
Using the user object as returned from supabase.auth.getSession() or from some supabase.auth.onAuthStateChange() events could be insecure! This value comes directly from the storage medium (usually cookies on the server) and may not be authentic. Use supabase.auth.getUser() instead which authenticates the data by contacting the Supabase Auth server.
<--- Last few GCs --->
[30704:000001C2AAECC000] 3154411 ms: Mark-Compact 31843.8 (32807.5) -> 31742.3 (32803.9) MB, pooled: 0 MB, 10826.74 / 19.88 ms (average mu = 0.281, current mu = 0.289) task; scavenge might not succeed
[30704:000001C2AAECC000] 3167016 ms: Mark-Compact 31812.8 (32808.6) -> 31722.8 (32794.1) MB, pooled: 8 MB, 11662.27 / 4.09 ms (average mu = 0.187, current mu = 0.075) task; scavenge might not succeed
FATAL ERROR: Ineffective mark-compacts near heap limit Allocation failed - JavaScript heap out of memory
----- Native stack trace -----
1: 00007FF6DC3D7F8F node::OnFatalError+1343
2: 00007FF6DD01B1E7 v8::Function::NewInstance+423
3: 00007FF6DCE1BA97 v8::base::AddressSpaceReservation::AddressSpaceReservation+322071
4: 00007FF6DCE1F7A4 v8::base::AddressSpaceReservation::AddressSpaceReservation+337700
5: 00007FF6DCE2E73C v8::internal::StrongRootAllocatorBase::deallocate_impl+16604
6: 00007FF6DCE2DF7B v8::internal::StrongRootAllocatorBase::deallocate_impl+14619
7: 00007FF6DE295D2D v8::base::UnsignedDivisionByConstant<unsigned __int64>+2791341
8: 00007FF6DCE19560 v8::base::AddressSpaceReservation::AddressSpaceReservation+312544
9: 00007FF6DCDC9F12 EVP_PKEY_asn1_set_get_priv_key+86258
10: 00007FF6DC32A48A node::GetNodeReport+98346
11: 00007FF6DC328C70 node::GetNodeReport+92176
12: 00007FF6DD082BCB uv_run+1867
13: 00007FF6DD08273F uv_run+703
14: 00007FF6DC4E4E3F node::DecodeWrite+367
15: 00007FF6DC3659B2 node::MultiIsolatePlatform::DisposeIsolate+240642
16: 00007FF6DC430F4C node::Start+1052
17: 00007FF6DD449A82 AES_cbc_encrypt+2546
18: 00007FF6DE29F434 v8::base::UnsignedDivisionByConstant<unsigned __int64>+2830004
19: 00007FFEDDDAE8D7 BaseThreadInitThunk+23
20: 00007FFEDFAAC53C RtlUserThreadStart+44
+45
View File
@@ -0,0 +1,45 @@
> wolai-frontend@0.1.0 dev F:\SOFT\MNOTE\wolai-frontend
> next dev
▲ Next.js 16.0.3 (Turbopack)
- Local: http://localhost:3000
- Network: http://192.168.121.1:3000
- Environments: .env.local
✓ Starting...
✓ Ready in 983ms
○ Compiling /documents/[id] ...
GET /login 200 in 2.4s (compile: 1432ms, render: 975ms)
POST /api/auth/callback 200 in 460ms (compile: 439ms, render: 21ms)
GET /login 200 in 632ms (compile: 31ms, render: 601ms)
GET /documents/fe527e35-08c8-45db-8cc5-4e040b40973e 307 in 6.2s (compile: 5.1s, render: 1160ms)
POST /api/auth/callback 200 in 42ms (compile: 29ms, render: 13ms)
GET /login 200 in 639ms (compile: 35ms, render: 603ms)
POST /api/auth/callback 200 in 48ms (compile: 22ms, render: 26ms)
POST /api/auth/callback 200 in 146ms (compile: 45ms, render: 101ms)
POST /api/auth/callback 200 in 200ms (compile: 49ms, render: 151ms)
GET / 200 in 1154ms (compile: 432ms, render: 723ms)
GET /login 200 in 699ms (compile: 29ms, render: 670ms)
POST /api/auth/callback 200 in 55ms (compile: 44ms, render: 12ms)
POST /api/auth/callback 200 in 98ms (compile: 47ms, render: 50ms)
POST /api/auth/callback 200 in 132ms (compile: 36ms, render: 96ms)
POST /api/auth/callback 200 in 133ms (compile: 40ms, render: 93ms)
POST /api/auth/callback 200 in 141ms (compile: 39ms, render: 102ms)
POST /api/auth/callback 200 in 188ms (compile: 44ms, render: 144ms)
GET / 200 in 788ms (compile: 52ms, render: 736ms)
GET /login 200 in 711ms (compile: 20ms, render: 691ms)
POST /api/auth/callback 200 in 57ms (compile: 37ms, render: 21ms)
POST /api/auth/callback 200 in 59ms (compile: 41ms, render: 18ms)
POST /api/auth/callback 200 in 130ms (compile: 22ms, render: 108ms)
POST /api/auth/callback 200 in 132ms (compile: 33ms, render: 99ms)
POST /api/auth/callback 200 in 136ms (compile: 34ms, render: 102ms)
POST /api/auth/callback 200 in 171ms (compile: 37ms, render: 134ms)
GET / 200 in 900ms (compile: 44ms, render: 856ms)
GET /documents/6fd575a8-1ca5-41f8-9366-2d8c400efd02 200 in 1754ms (compile: 65ms, render: 1689ms)
GET /documents/6fd575a8-1ca5-41f8-9366-2d8c400efd02 200 in 1323ms (compile: 61ms, render: 1262ms)
GET /api/references/backlinks?workspaceId=637d70cb-fbe1-4dc3-9f2c-ff8e099b1225&pageId=6fd575a8-1ca5-41f8-9366-2d8c400efd02 200 in 252ms (compile: 156ms, render: 96ms)
POST /api/documents/save 200 in 174ms (compile: 134ms, render: 40ms)
POST /api/documents/stats 200 in 192ms (compile: 156ms, render: 37ms)
[?25h
ELIFECYCLE Command failed with exit code 134.
+1
View File
@@ -0,0 +1 @@
27684
@@ -0,0 +1,539 @@
/* Logo 字体 */
@font-face {
font-family: "iconfont logo";
src: url('https://at.alicdn.com/t/font_985780_km7mi63cihi.eot?t=1545807318834');
src: url('https://at.alicdn.com/t/font_985780_km7mi63cihi.eot?t=1545807318834#iefix') format('embedded-opentype'),
url('https://at.alicdn.com/t/font_985780_km7mi63cihi.woff?t=1545807318834') format('woff'),
url('https://at.alicdn.com/t/font_985780_km7mi63cihi.ttf?t=1545807318834') format('truetype'),
url('https://at.alicdn.com/t/font_985780_km7mi63cihi.svg?t=1545807318834#iconfont') format('svg');
}
.logo {
font-family: "iconfont logo";
font-size: 160px;
font-style: normal;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* tabs */
.nav-tabs {
position: relative;
}
.nav-tabs .nav-more {
position: absolute;
right: 0;
bottom: 0;
height: 42px;
line-height: 42px;
color: #666;
}
#tabs {
border-bottom: 1px solid #eee;
}
#tabs li {
cursor: pointer;
width: 100px;
height: 40px;
line-height: 40px;
text-align: center;
font-size: 16px;
border-bottom: 2px solid transparent;
position: relative;
z-index: 1;
margin-bottom: -1px;
color: #666;
}
#tabs .active {
border-bottom-color: #f00;
color: #222;
}
.tab-container .content {
display: none;
}
/* 页面布局 */
.main {
padding: 30px 100px;
width: 960px;
margin: 0 auto;
}
.main .logo {
color: #333;
text-align: left;
margin-bottom: 30px;
line-height: 1;
height: 110px;
margin-top: -50px;
overflow: hidden;
*zoom: 1;
}
.main .logo a {
font-size: 160px;
color: #333;
}
.helps {
margin-top: 40px;
}
.helps pre {
padding: 20px;
margin: 10px 0;
border: solid 1px #e7e1cd;
background-color: #fffdef;
overflow: auto;
}
.icon_lists {
width: 100% !important;
overflow: hidden;
*zoom: 1;
}
.icon_lists li {
width: 100px;
margin-bottom: 10px;
margin-right: 20px;
text-align: center;
list-style: none !important;
cursor: default;
}
.icon_lists li .code-name {
line-height: 1.2;
}
.icon_lists .icon {
display: block;
height: 100px;
line-height: 100px;
font-size: 42px;
margin: 10px auto;
color: #333;
-webkit-transition: font-size 0.25s linear, width 0.25s linear;
-moz-transition: font-size 0.25s linear, width 0.25s linear;
transition: font-size 0.25s linear, width 0.25s linear;
}
.icon_lists .icon:hover {
font-size: 100px;
}
.icon_lists .svg-icon {
/* 通过设置 font-size 来改变图标大小 */
width: 1em;
/* 图标和文字相邻时,垂直对齐 */
vertical-align: -0.15em;
/* 通过设置 color 来改变 SVG 的颜色/fill */
fill: currentColor;
/* path 和 stroke 溢出 viewBox 部分在 IE 下会显示
normalize.css 中也包含这行 */
overflow: hidden;
}
.icon_lists li .name,
.icon_lists li .code-name {
color: #666;
}
/* markdown 样式 */
.markdown {
color: #666;
font-size: 14px;
line-height: 1.8;
}
.highlight {
line-height: 1.5;
}
.markdown img {
vertical-align: middle;
max-width: 100%;
}
.markdown h1 {
color: #404040;
font-weight: 500;
line-height: 40px;
margin-bottom: 24px;
}
.markdown h2,
.markdown h3,
.markdown h4,
.markdown h5,
.markdown h6 {
color: #404040;
margin: 1.6em 0 0.6em 0;
font-weight: 500;
clear: both;
}
.markdown h1 {
font-size: 28px;
}
.markdown h2 {
font-size: 22px;
}
.markdown h3 {
font-size: 16px;
}
.markdown h4 {
font-size: 14px;
}
.markdown h5 {
font-size: 12px;
}
.markdown h6 {
font-size: 12px;
}
.markdown hr {
height: 1px;
border: 0;
background: #e9e9e9;
margin: 16px 0;
clear: both;
}
.markdown p {
margin: 1em 0;
}
.markdown>p,
.markdown>blockquote,
.markdown>.highlight,
.markdown>ol,
.markdown>ul {
width: 80%;
}
.markdown ul>li {
list-style: circle;
}
.markdown>ul li,
.markdown blockquote ul>li {
margin-left: 20px;
padding-left: 4px;
}
.markdown>ul li p,
.markdown>ol li p {
margin: 0.6em 0;
}
.markdown ol>li {
list-style: decimal;
}
.markdown>ol li,
.markdown blockquote ol>li {
margin-left: 20px;
padding-left: 4px;
}
.markdown code {
margin: 0 3px;
padding: 0 5px;
background: #eee;
border-radius: 3px;
}
.markdown strong,
.markdown b {
font-weight: 600;
}
.markdown>table {
border-collapse: collapse;
border-spacing: 0px;
empty-cells: show;
border: 1px solid #e9e9e9;
width: 95%;
margin-bottom: 24px;
}
.markdown>table th {
white-space: nowrap;
color: #333;
font-weight: 600;
}
.markdown>table th,
.markdown>table td {
border: 1px solid #e9e9e9;
padding: 8px 16px;
text-align: left;
}
.markdown>table th {
background: #F7F7F7;
}
.markdown blockquote {
font-size: 90%;
color: #999;
border-left: 4px solid #e9e9e9;
padding-left: 0.8em;
margin: 1em 0;
}
.markdown blockquote p {
margin: 0;
}
.markdown .anchor {
opacity: 0;
transition: opacity 0.3s ease;
margin-left: 8px;
}
.markdown .waiting {
color: #ccc;
}
.markdown h1:hover .anchor,
.markdown h2:hover .anchor,
.markdown h3:hover .anchor,
.markdown h4:hover .anchor,
.markdown h5:hover .anchor,
.markdown h6:hover .anchor {
opacity: 1;
display: inline-block;
}
.markdown>br,
.markdown>p>br {
clear: both;
}
.hljs {
display: block;
background: white;
padding: 0.5em;
color: #333333;
overflow-x: auto;
}
.hljs-comment,
.hljs-meta {
color: #969896;
}
.hljs-string,
.hljs-variable,
.hljs-template-variable,
.hljs-strong,
.hljs-emphasis,
.hljs-quote {
color: #df5000;
}
.hljs-keyword,
.hljs-selector-tag,
.hljs-type {
color: #a71d5d;
}
.hljs-literal,
.hljs-symbol,
.hljs-bullet,
.hljs-attribute {
color: #0086b3;
}
.hljs-section,
.hljs-name {
color: #63a35c;
}
.hljs-tag {
color: #333333;
}
.hljs-title,
.hljs-attr,
.hljs-selector-id,
.hljs-selector-class,
.hljs-selector-attr,
.hljs-selector-pseudo {
color: #795da3;
}
.hljs-addition {
color: #55a532;
background-color: #eaffea;
}
.hljs-deletion {
color: #bd2c00;
background-color: #ffecec;
}
.hljs-link {
text-decoration: underline;
}
/* 代码高亮 */
/* PrismJS 1.15.0
https://prismjs.com/download.html#themes=prism&languages=markup+css+clike+javascript */
/**
* prism.js default theme for JavaScript, CSS and HTML
* Based on dabblet (http://dabblet.com)
* @author Lea Verou
*/
code[class*="language-"],
pre[class*="language-"] {
color: black;
background: none;
text-shadow: 0 1px white;
font-family: Consolas, Monaco, 'Andale Mono', 'Ubuntu Mono', monospace;
text-align: left;
white-space: pre;
word-spacing: normal;
word-break: normal;
word-wrap: normal;
line-height: 1.5;
-moz-tab-size: 4;
-o-tab-size: 4;
tab-size: 4;
-webkit-hyphens: none;
-moz-hyphens: none;
-ms-hyphens: none;
hyphens: none;
}
pre[class*="language-"]::-moz-selection,
pre[class*="language-"] ::-moz-selection,
code[class*="language-"]::-moz-selection,
code[class*="language-"] ::-moz-selection {
text-shadow: none;
background: #b3d4fc;
}
pre[class*="language-"]::selection,
pre[class*="language-"] ::selection,
code[class*="language-"]::selection,
code[class*="language-"] ::selection {
text-shadow: none;
background: #b3d4fc;
}
@media print {
code[class*="language-"],
pre[class*="language-"] {
text-shadow: none;
}
}
/* Code blocks */
pre[class*="language-"] {
padding: 1em;
margin: .5em 0;
overflow: auto;
}
:not(pre)>code[class*="language-"],
pre[class*="language-"] {
background: #f5f2f0;
}
/* Inline code */
:not(pre)>code[class*="language-"] {
padding: .1em;
border-radius: .3em;
white-space: normal;
}
.token.comment,
.token.prolog,
.token.doctype,
.token.cdata {
color: slategray;
}
.token.punctuation {
color: #999;
}
.namespace {
opacity: .7;
}
.token.property,
.token.tag,
.token.boolean,
.token.number,
.token.constant,
.token.symbol,
.token.deleted {
color: #905;
}
.token.selector,
.token.attr-name,
.token.string,
.token.char,
.token.builtin,
.token.inserted {
color: #690;
}
.token.operator,
.token.entity,
.token.url,
.language-css .token.string,
.style .token.string {
color: #9a6e3a;
background: hsla(0, 0%, 100%, .5);
}
.token.atrule,
.token.attr-value,
.token.keyword {
color: #07a;
}
.token.function,
.token.class-name {
color: #DD4A68;
}
.token.regex,
.token.important,
.token.variable {
color: #e90;
}
.token.important,
.token.bold {
font-weight: bold;
}
.token.italic {
font-style: italic;
}
.token.entity {
cursor: help;
}
@@ -0,0 +1,326 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8"/>
<title>iconfont Demo</title>
<link rel="shortcut icon" href="//img.alicdn.com/imgextra/i4/O1CN01Z5paLz1O0zuCC7osS_!!6000000001644-55-tps-83-82.svg" type="image/x-icon"/>
<link rel="icon" type="image/svg+xml" href="//img.alicdn.com/imgextra/i4/O1CN01Z5paLz1O0zuCC7osS_!!6000000001644-55-tps-83-82.svg"/>
<link rel="stylesheet" href="https://g.alicdn.com/thx/cube/1.3.2/cube.min.css">
<link rel="stylesheet" href="demo.css">
<link rel="stylesheet" href="iconfont.css">
<script src="iconfont.js"></script>
<!-- jQuery -->
<script src="https://a1.alicdn.com/oss/uploads/2018/12/26/7bfddb60-08e8-11e9-9b04-53e73bb6408b.js"></script>
<!-- 代码高亮 -->
<script src="https://a1.alicdn.com/oss/uploads/2018/12/26/a3f714d0-08e6-11e9-8a15-ebf944d7534c.js"></script>
<style>
.main .logo {
margin-top: 0;
height: auto;
}
.main .logo a {
display: flex;
align-items: center;
}
.main .logo .sub-title {
margin-left: 0.5em;
font-size: 22px;
color: #fff;
background: linear-gradient(-45deg, #3967FF, #B500FE);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
</style>
</head>
<body>
<div class="main">
<h1 class="logo"><a href="https://www.iconfont.cn/" title="iconfont 首页" target="_blank">
<img width="200" src="https://img.alicdn.com/imgextra/i3/O1CN01Mn65HV1FfSEzR6DKv_!!6000000000514-55-tps-228-59.svg">
</a></h1>
<div class="nav-tabs">
<ul id="tabs" class="dib-box">
<li class="dib active"><span>Unicode</span></li>
<li class="dib"><span>Font class</span></li>
<li class="dib"><span>Symbol</span></li>
</ul>
<a href="https://www.iconfont.cn/manage/index?manage_type=myprojects&projectId=5013087" target="_blank" class="nav-more">查看项目</a>
</div>
<div class="tab-container">
<div class="content unicode" style="display: block;">
<ul class="icon_lists dib-box">
<li class="dib">
<span class="icon iconfont">&#xeb00;</span>
<div class="name">border-rt-lb</div>
<div class="code-name">&amp;#xeb00;</div>
</li>
<li class="dib">
<span class="icon iconfont">&#xeb01;</span>
<div class="name">border-lt-rb</div>
<div class="code-name">&amp;#xeb01;</div>
</li>
<li class="dib">
<span class="icon iconfont">&#xe74b;</span>
<div class="name">树形图</div>
<div class="code-name">&amp;#xe74b;</div>
</li>
<li class="dib">
<span class="icon iconfont">&#xe64f;</span>
<div class="name">浮动图片</div>
<div class="code-name">&amp;#xe64f;</div>
</li>
<li class="dib">
<span class="icon iconfont">&#xe60f;</span>
<div class="name">单元格图片</div>
<div class="code-name">&amp;#xe60f;</div>
</li>
<li class="dib">
<span class="icon iconfont">&#xe625;</span>
<div class="name">转换、交换</div>
<div class="code-name">&amp;#xe625;</div>
</li>
</ul>
<div class="article markdown">
<h2 id="unicode-">Unicode 引用</h2>
<hr>
<p>Unicode 是字体在网页端最原始的应用方式,特点是:</p>
<ul>
<li>支持按字体的方式去动态调整图标大小,颜色等等。</li>
<li>默认情况下不支持多色,直接添加多色图标会自动去色。</li>
</ul>
<blockquote>
<p>注意:新版 iconfont 支持两种方式引用多色图标:SVG symbol 引用方式和彩色字体图标模式。(使用彩色字体图标需要在「编辑项目」中开启「彩色」选项后并重新生成。)</p>
</blockquote>
<p>Unicode 使用步骤如下:</p>
<h3 id="-font-face">第一步:拷贝项目下面生成的 <code>@font-face</code></h3>
<pre><code class="language-css"
>@font-face {
font-family: 'iconfont';
src: url('iconfont.woff2?t=1760058905416') format('woff2'),
url('iconfont.woff?t=1760058905416') format('woff'),
url('iconfont.ttf?t=1760058905416') format('truetype');
}
</code></pre>
<h3 id="-iconfont-">第二步:定义使用 iconfont 的样式</h3>
<pre><code class="language-css"
>.iconfont {
font-family: "iconfont" !important;
font-size: 16px;
font-style: normal;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
</code></pre>
<h3 id="-">第三步:挑选相应图标并获取字体编码,应用于页面</h3>
<pre>
<code class="language-html"
>&lt;span class="iconfont"&gt;&amp;#x33;&lt;/span&gt;
</code></pre>
<blockquote>
<p>"iconfont" 是你项目下的 font-family。可以通过编辑项目查看,默认是 "iconfont"。</p>
</blockquote>
</div>
</div>
<div class="content font-class">
<ul class="icon_lists dib-box">
<li class="dib">
<span class="icon iconfont icon-border-rt-lb"></span>
<div class="name">
border-rt-lb
</div>
<div class="code-name">.icon-border-rt-lb
</div>
</li>
<li class="dib">
<span class="icon iconfont icon-border-lt-rb"></span>
<div class="name">
border-lt-rb
</div>
<div class="code-name">.icon-border-lt-rb
</div>
</li>
<li class="dib">
<span class="icon iconfont icon-shuxingtu"></span>
<div class="name">
树形图
</div>
<div class="code-name">.icon-shuxingtu
</div>
</li>
<li class="dib">
<span class="icon iconfont icon-fudongtupian"></span>
<div class="name">
浮动图片
</div>
<div class="code-name">.icon-fudongtupian
</div>
</li>
<li class="dib">
<span class="icon iconfont icon-danyuangetupian"></span>
<div class="name">
单元格图片
</div>
<div class="code-name">.icon-danyuangetupian
</div>
</li>
<li class="dib">
<span class="icon iconfont icon-a-zhuanhuanjiaohuan"></span>
<div class="name">
转换、交换
</div>
<div class="code-name">.icon-a-zhuanhuanjiaohuan
</div>
</li>
</ul>
<div class="article markdown">
<h2 id="font-class-">font-class 引用</h2>
<hr>
<p>font-class 是 Unicode 使用方式的一种变种,主要是解决 Unicode 书写不直观,语意不明确的问题。</p>
<p>与 Unicode 使用方式相比,具有如下特点:</p>
<ul>
<li>相比于 Unicode 语意明确,书写更直观。可以很容易分辨这个 icon 是什么。</li>
<li>因为使用 class 来定义图标,所以当要替换图标时,只需要修改 class 里面的 Unicode 引用。</li>
</ul>
<p>使用步骤如下:</p>
<h3 id="-fontclass-">第一步:引入项目下面生成的 fontclass 代码:</h3>
<pre><code class="language-html">&lt;link rel="stylesheet" href="./iconfont.css"&gt;
</code></pre>
<h3 id="-">第二步:挑选相应图标并获取类名,应用于页面:</h3>
<pre><code class="language-html">&lt;span class="iconfont icon-xxx"&gt;&lt;/span&gt;
</code></pre>
<blockquote>
<p>"
iconfont" 是你项目下的 font-family。可以通过编辑项目查看,默认是 "iconfont"。</p>
</blockquote>
</div>
</div>
<div class="content symbol">
<ul class="icon_lists dib-box">
<li class="dib">
<svg class="icon svg-icon" aria-hidden="true">
<use xlink:href="#icon-border-rt-lb"></use>
</svg>
<div class="name">border-rt-lb</div>
<div class="code-name">#icon-border-rt-lb</div>
</li>
<li class="dib">
<svg class="icon svg-icon" aria-hidden="true">
<use xlink:href="#icon-border-lt-rb"></use>
</svg>
<div class="name">border-lt-rb</div>
<div class="code-name">#icon-border-lt-rb</div>
</li>
<li class="dib">
<svg class="icon svg-icon" aria-hidden="true">
<use xlink:href="#icon-shuxingtu"></use>
</svg>
<div class="name">树形图</div>
<div class="code-name">#icon-shuxingtu</div>
</li>
<li class="dib">
<svg class="icon svg-icon" aria-hidden="true">
<use xlink:href="#icon-fudongtupian"></use>
</svg>
<div class="name">浮动图片</div>
<div class="code-name">#icon-fudongtupian</div>
</li>
<li class="dib">
<svg class="icon svg-icon" aria-hidden="true">
<use xlink:href="#icon-danyuangetupian"></use>
</svg>
<div class="name">单元格图片</div>
<div class="code-name">#icon-danyuangetupian</div>
</li>
<li class="dib">
<svg class="icon svg-icon" aria-hidden="true">
<use xlink:href="#icon-a-zhuanhuanjiaohuan"></use>
</svg>
<div class="name">转换、交换</div>
<div class="code-name">#icon-a-zhuanhuanjiaohuan</div>
</li>
</ul>
<div class="article markdown">
<h2 id="symbol-">Symbol 引用</h2>
<hr>
<p>这是一种全新的使用方式,应该说这才是未来的主流,也是平台目前推荐的用法。相关介绍可以参考这篇<a href="">文章</a>
这种用法其实是做了一个 SVG 的集合,与另外两种相比具有如下特点:</p>
<ul>
<li>支持多色图标了,不再受单色限制。</li>
<li>通过一些技巧,支持像字体那样,通过 <code>font-size</code>, <code>color</code> 来调整样式。</li>
<li>兼容性较差,支持 IE9+,及现代浏览器。</li>
<li>浏览器渲染 SVG 的性能一般,还不如 png。</li>
</ul>
<p>使用步骤如下:</p>
<h3 id="-symbol-">第一步:引入项目下面生成的 symbol 代码:</h3>
<pre><code class="language-html">&lt;script src="./iconfont.js"&gt;&lt;/script&gt;
</code></pre>
<h3 id="-css-">第二步:加入通用 CSS 代码(引入一次就行):</h3>
<pre><code class="language-html">&lt;style&gt;
.icon {
width: 1em;
height: 1em;
vertical-align: -0.15em;
fill: currentColor;
overflow: hidden;
}
&lt;/style&gt;
</code></pre>
<h3 id="-">第三步:挑选相应图标并获取类名,应用于页面:</h3>
<pre><code class="language-html">&lt;svg class="icon" aria-hidden="true"&gt;
&lt;use xlink:href="#icon-xxx"&gt;&lt;/use&gt;
&lt;/svg&gt;
</code></pre>
</div>
</div>
</div>
</div>
<script>
$(document).ready(function () {
$('.tab-container .content:first').show()
$('#tabs li').click(function (e) {
var tabContent = $('.tab-container .content')
var index = $(this).index()
if ($(this).hasClass('active')) {
return
} else {
$('#tabs li').removeClass('active')
$(this).addClass('active')
tabContent.hide().eq(index).fadeIn()
}
})
})
</script>
</body>
</html>
@@ -0,0 +1,39 @@
@font-face {
font-family: "iconfont"; /* Project id 5013087 */
src: url('iconfont.woff2?t=1760058905416') format('woff2'),
url('iconfont.woff?t=1760058905416') format('woff'),
url('iconfont.ttf?t=1760058905416') format('truetype');
}
.iconfont {
font-family: "iconfont" !important;
font-size: 16px;
font-style: normal;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.icon-border-rt-lb:before {
content: "\eb00";
}
.icon-border-lt-rb:before {
content: "\eb01";
}
.icon-shuxingtu:before {
content: "\e74b";
}
.icon-fudongtupian:before {
content: "\e64f";
}
.icon-danyuangetupian:before {
content: "\e60f";
}
.icon-a-zhuanhuanjiaohuan:before {
content: "\e625";
}
File diff suppressed because one or more lines are too long
@@ -0,0 +1,51 @@
{
"id": "5013087",
"name": "sheet-demo",
"font_family": "iconfont",
"css_prefix_text": "icon-",
"description": "",
"glyphs": [
{
"icon_id": "7596484",
"name": "border-rt-lb",
"font_class": "border-rt-lb",
"unicode": "eb00",
"unicode_decimal": 60160
},
{
"icon_id": "45726050",
"name": "border-lt-rb",
"font_class": "border-lt-rb",
"unicode": "eb01",
"unicode_decimal": 60161
},
{
"icon_id": "25495312",
"name": "树形图",
"font_class": "shuxingtu",
"unicode": "e74b",
"unicode_decimal": 59211
},
{
"icon_id": "1754409",
"name": "浮动图片",
"font_class": "fudongtupian",
"unicode": "e64f",
"unicode_decimal": 58959
},
{
"icon_id": "13932187",
"name": "单元格图片",
"font_class": "danyuangetupian",
"unicode": "e60f",
"unicode_decimal": 58895
},
{
"icon_id": "25634520",
"name": "转换、交换",
"font_class": "a-zhuanhuanjiaohuan",
"unicode": "e625",
"unicode_decimal": 58917
}
]
}
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

File diff suppressed because one or more lines are too long
@@ -1,410 +0,0 @@
export function initChat() {
if (!isNeedChat()) {
return
}
// Your CSS as text
let styles = `
body {
background-color: #f5f5f5;
}
#chat-assistant-container {
position: fixed;
right: 40px;
bottom: 86px;
z-index:9990;
}
#chat-assistant-button {
width: 50px;
height: 50px;
border-radius: 50%;
border: none;
display: flex;
justify-content: center;
align-items: center;
cursor: pointer;
background: linear-gradient(135deg, rgb(215 98 150 / 55%),rgb(34 78 139 / 71%), rgb(114 222 172));
box-shadow: 0px 0px 8px 1px rgb(0 0 0 / 22%);
color: #fff;
text-shadow: 1px 1px 3px rgb(0 0 0 / 56%);
}
#chat-container {
position: fixed;
padding: 10px;
top: 45%;
left: 50%;
z-index:9990;
transform: translate(-50%, -50%);
display: none;
border-radius: 5px;
width: 40%;
background: linear-gradient(135deg, rgb(215 98 150 / 92%),rgb(34 78 139 / 93%), rgb(114 222 172 / 94%));
box-shadow: 0px 2px 5px rgba(0, 0, 0, 0.2);
}
#chat-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 10px 10px 0;
border-radius: 5px 5px 0 0;
cursor: move;
}
#loading-indicator {
width: 14px;
height: 14px;
margin: 0 10px 0 10px;
border: 2px solid #ccc;
border-top-color: #4caf50;
border-radius: 50%;
animation: spin 2s linear infinite;
visibility: hidden;
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
#chat-header .show-loading {
visibility: visible;
}
#chat-header .hide-loading {
visibility: hidden;
}
#circle-button {
padding: 0;
border: none;
background-color: transparent;
font-size: 16px;
user-select: none;
display: flex;
align-items: center;
color: #fff;
text-shadow: 1px 1px 3px black;
}
#close-button {
cursor: pointer;
padding: 0;
border: none;
background-color: transparent;
font-size: 24px;
color: #fff;
text-shadow: 1px 1px 3px black;
}
#send-button {
cursor: pointer;
padding: 0;
border: none;
background-color: transparent;
font-size: 16px;
}
#close-button:hover,
#send-button:hover {
color: #888;
}
#chat-input-container,
#chat-input {
border: none;
}
#chat-input-container {
display: flex;
align-items: center;
border-radius: 5px;
background-color: #fff;
padding: 10px;
}
#chat-input {
flex: 1;
padding: 0;
margin-right: 5px;
border-radius: 5px;
overflow-y: auto;
height: 24px;
font-size: 1rem;
outline: none;
resize: none;
background: transparent;
}
#send-button {
background-color: transparent;
border: none;
border-radius: 5px;
cursor: pointer;
padding: 5px;
display: flex;
align-items: center;
justify-content: center;
height: 32px;
width: 32px;
}
#send-button>span {
height: 16px;
width: 16px;
}
#send-button:enabled {
background-color: rgb(120,198,174);
}
#send-button:enabled svg path {
fill: #fff;
}
`
let styleSheet = document.createElement("style")
styleSheet.innerText = styles
document.head.appendChild(styleSheet)
const html = `<div id="chat-assistant-container">
<button id="chat-assistant-button">🤖AI</button>
</div>
<div id="chat-container">
<div id="chat-header">
<span id="circle-button">Univer AI 助手<div id="loading-indicator"></div></span>
<button id="close-button">×</button>
</div>
<div id="chat-input-container">
<textarea id="chat-input" placeholder="请输入问题"></textarea>
<!-- <textarea id="chat-input" placeholder="请输入问题"></textarea> -->
<button id="send-button" disabled>
<span><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="none" class="h-4 w-4 m-1 md:m-0"
stroke-width="2">
<path
d="M.5 1.163A1 1 0 0 1 1.97.28l12.868 6.837a1 1 0 0 1 0 1.766L1.969 15.72A1 1 0 0 1 .5 14.836V10.33a1 1 0 0 1 .816-.983L8.5 8 1.316 6.653A1 1 0 0 1 .5 5.67V1.163Z"
fill="currentColor"></path>
</svg></span>
</button>
</div>
</div>`;
document.body.insertAdjacentHTML('beforeend', html)
const assistantButton = document.getElementById('chat-assistant-button');
const chatContainer = document.getElementById('chat-container');
const closeButton = document.getElementById('close-button');
const chatInput = document.getElementById('chat-input');
const sendButton = document.getElementById('send-button');
const loadingIndicator = document.getElementById('loading-indicator');
assistantButton.addEventListener('click', function () {
chatContainer.style.display = 'block';
});
closeButton.addEventListener('click', function () {
chatContainer.style.display = 'none';
});
sendButton.addEventListener('click', function () {
const message = chatInput.value;
if (message.trim() !== '') {
// 处理发送消息的逻辑
chatInput.value = '';
resetButton(chatInput)
// 显示 Loading
loadingIndicator.classList.add('show-loading');
setTimeout(() => {
setFormuala(message);
// 隐藏 Loading
loadingIndicator.classList.remove('show-loading');
}, 1000);
}
});
chatInput.addEventListener('input', function () {
inputHandler(this)
});
function inputHandler(input) {
if (input.scrollHeight > 24) {
input.style.height = 'auto'
}
input.style.height = input.scrollHeight + 'px'; // 根据内容高度设置 textarea 高度
if (input.scrollHeight > 200) {
input.style.overflowY = 'scroll'
} else {
input.style.overflowY = 'hidden'
}
resetButton(input)
}
function resetButton(input) {
if (input.value.trim() !== '') {
sendButton.disabled = false;
sendButton.classList.add('enabled');
} else {
input.style.height = '24px'; // 重置高度为一行
sendButton.disabled = true;
sendButton.classList.remove('enabled');
}
}
// 快捷键
let isComposing = false;
chatInput.addEventListener('compositionstart', function () {
isComposing = true;
});
chatInput.addEventListener('compositionend', function () {
isComposing = false;
});
chatInput.addEventListener('keydown', function (event) {
const isWindows = navigator.platform.includes('Win');
const isMac = navigator.platform.includes('Mac');
const key = event.key;
if (isWindows && event.key === 'Enter' && !isComposing && !event.altKey) {
// Windows 上的 Enter 键触发发送
event.preventDefault();
sendButton.click();
} else if (isWindows && event.key === 'Enter' && !isComposing && event.altKey) {
// Windows 上的 Alt+Enter 键触发换行
event.preventDefault();
this.value += '\n';
} else if (isMac && event.key === 'Enter' && !isComposing && !event.metaKey) {
// Mac 上的 Enter 键触发发送
event.preventDefault();
sendButton.click();
} else if (isMac && event.key === 'Enter' && !isComposing && event.metaKey) {
// Mac 上的 Command+Enter 键触发换行
event.preventDefault();
this.value += '\n';
} else if (!isComposing && (key === "Backspace" || key === "Delete")) {
}
inputHandler(this)
});
// 添加拖拽功能
let isDragging = false;
let offset = { x: 0, y: 0 };
const chatHeader = document.getElementById('chat-header');
chatHeader.addEventListener('mousedown', function (event) {
isDragging = true;
offset.x = event.clientX - chatContainer.offsetLeft;
offset.y = event.clientY - chatContainer.offsetTop;
});
document.addEventListener('mousemove', function (event) {
if (isDragging) {
chatContainer.style.left = `${event.clientX - offset.x}px`;
chatContainer.style.top = `${event.clientY - offset.y}px`;
}
});
document.addEventListener('mouseup', function () {
isDragging = false;
});
}
const needChatHosts = [
'crm.lashuju.com',
'localhost:3000'
]
function isNeedChat() {
const host = location.host;
if (needChatHosts.includes(host)) {
return true
}
return false
}
function setFormuala(sentence = '') {
let link = getLink(sentence)
if (link !== '') {
setGET_AIRTABLE(link)
} else {
setASK_AI(sentence)
}
}
function setASK_AI(sentence = '') {
let range = getRange(sentence);
range = range === '' ? '' : ',' + range
const data = [
[
{
"f": "=ASK_AI(\"" + sentence + "\"" + range + ")"
}
]
]
luckysheet.setRangeValue(data)
}
function setGET_AIRTABLE(link) {
const data = [
[
{
"f": "=GET_AIRTABLE_DATA(\"" + link + "\")"
}
]
]
luckysheet.setRangeValue(data)
}
function getLink(sentence = '') {
const regex = /(https?:\/\/(?:www\.)?airtable\.com\/\S+)/gi;
const matches = sentence.match(regex);
if (matches) {
return matches[0];
}
return ''
}
function getRange(text) {
const regex = /([A-Z]+[0-9]*):([A-Z]+[0-9]*)/g;
const matche = text.match(regex);
if (matche) {
return matche[0]
}
return ''
}
@@ -1,42 +0,0 @@
// Features specially written for demo
(function () {
// language
function language(params) {
var lang = navigator.language || navigator.userLanguage;//常规浏览器语言和IE浏览器
lang = lang.substr(0, 2);//截取lang前2位字符
return lang;
}
// Tencent Forum Link Button
function supportButton() {
const text = language() === 'zh' ? '反馈' : 'Forum';
const link = language() === 'zh' ? 'https://support.qq.com/product/288322' : 'https://groups.google.com/g/luckysheet';
document.querySelector("body").insertAdjacentHTML('beforeend', '<a id="container" href="' + link + '" target="_blank" style="z-index:2;width:50px;height:50px;line-height:50px;position:fixed;right:40px;bottom:86px;border-radius:50px;cursor:pointer;background:rgb(71,133,249);color:#fff;text-align:center;text-decoration:none;font-size: 12px;">' + text + '</a>');
}
supportButton()
/**
* Get url parameters
*/
function getRequest() {
var vars = {};
var parts = window.location.href.replace(/[?&]+([^=&]+)=([^&]*)/gi,
function (m, key, value) {
vars[key] = value;
});
return vars;
}
window.luckysheetDemoUtil = {
language: language,
getRequest: getRequest
}
})()
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
@@ -1,67 +0,0 @@
window.sheetComment = {
"name": "Comment",
"color": "",
"config": {
"columnlen": {
"2": 102
}
},
"index": "5",
"chart": [],
"status": 0,
"order": "5",
"column": 18,
"row": 36,
"celldata": [{
"r": 2,
"c": 2,
"v": {
"m": "HoverShown",
"ct": {
"fa": "General",
"t": "g"
},
"v": "HoverShown",
"bl": 1,
"ps": {
"left": null,
"top": null,
"width": null,
"height": null,
"value": "Hello world!",
"isshow": false
}
}
}, {
"r": 7,
"c": 2,
"v": {
"m": "Size",
"ct": {
"fa": "General",
"t": "g"
},
"v": "Size",
"bl": 1,
"ps": {
"left": null,
"top": null,
"width": null,
"height": null,
"value": "Hello,world!",
"isshow": true
}
}
}],
"ch_width": 4748,
"rh_height": 1790,
"luckysheet_select_save": [{
"row": [0, 0],
"column": [0, 0]
}],
"luckysheet_selection_range": [],
"scrollLeft": 0,
"scrollTop": 0
}
// export default sheetComment;
File diff suppressed because it is too large Load Diff
@@ -1,579 +0,0 @@
window.sheetDataVerification = {
"name": "Data Verification",
"index": "Sheet_pdolzzie5xwi_1600927444446",
"celldata": [{"r":0,"c":0,"v":{"ct":{"fa":"General","t":"g"},"m":"Drop Down List","v":"Drop Down List","bl":1}},{"r":0,"c":1,"v":{"m":"Checkbox","ct":{"fa":"General","t":"g"},"v":"Checkbox","bl":1}},{"r":0,"c":2,"v":{"ct":{"fa":"General","t":"g"},"v":"Number between 1-10","bl":1,"m":"Number between 1-10"}},{"r":0,"c":3,"v":{"m":"Text content include Luckysheet","ct":{"fa":"General","t":"g"},"v":"Text content include Luckysheet","bl":1}},{"r":0,"c":4,"v":{"ct":{"fa":"General","t":"g"},"v":"Text length between 1-5","m":"Text length between 1-5","bl":1}},{"r":0,"c":5,"v":{"m":"Date","ct":{"fa":"General","t":"g"},"v":"Date","bl":1}},{"r":0,"c":6,"v":{"m":"Identification Number","ct":{"fa":"General","t":"g"},"v":"Identification Number","bl":1}},{"r":0,"c":7,"v":{"m":"Phone Number","ct":{"fa":"General","t":"g"},"v":"Phone Number","bl":1}},{"r":1,"c":0,"v":{"ct":{"fa":"General","t":"g"},"v":"Fix","m":"Fix"}},{"r":1,"c":1,"v":{"m":"Fail","ct":{"fa":"General","t":"g"},"v":"Fail"}},{"r":1,"c":2,"v":{"v":1,"ct":{"fa":"General","t":"n"},"m":"1"}},{"r":1,"c":3,"v":{"m":"Luckysheet is good","ct":{"fa":"General","t":"g"},"v":"Luckysheet is good"}},{"r":1,"c":4,"v":{"m":"Welcome","ct":{"fa":"General","t":"g"},"v":"Welcome"}},{"r":1,"c":5,"v":{"m":"2020-09-24","ct":{"fa":"yyyy-MM-dd","t":"d"},"v":44098}},{"r":1,"c":6,"v":{"v":"311414199009138910","ct":{"fa":"@","t":"s"},"m":"311414199009138910"}},{"r":1,"c":7,"v":{"v":13678765439,"ct":{"fa":"General","t":"n"},"m":"13678765439"}},{"r":2,"c":0,"v":{"ct":{"fa":"General","t":"g"},"v":"Done","m":"Done"}},{"r":2,"c":1,"v":{"m":"Pass","ct":{"fa":"General","t":"g"},"v":"Pass"}},{"r":2,"c":2,"v":{"v":2,"ct":{"fa":"General","t":"n"},"m":"2"}},{"r":2,"c":3,"v":{"m":"I am Luckysheet","ct":{"fa":"General","t":"g"},"v":"I am Luckysheet"}},{"r":2,"c":4,"v":{"m":"Good","ct":{"fa":"General","t":"g"},"v":"Good"}},{"r":2,"c":5,"v":{"ct":{"fa":"General","t":"g"},"v":"Time","m":"Time"}},{"r":2,"c":6,"v":{"v":"31141419900913891","ct":{"fa":"@","t":"s"},"m":"31141419900913891"}},{"r":2,"c":7,"v":{"v":1367876544,"ct":{"fa":"General","t":"n"},"m":"1367876544"}},{"r":3,"c":0,"v":{"ct":{"fa":"General","t":"g"},"v":"Develop","m":"Develop"}},{"r":3,"c":1,"v":{"m":"Fail","ct":{"fa":"General","t":"g"},"v":"Fail"}},{"r":3,"c":2,"v":{"v":5,"ct":{"fa":"General","t":"n"},"m":"5"}},{"r":3,"c":3,"v":{"ct":{"fa":"General","t":"g"},"v":"I am luckyDemo","m":"I am luckyDemo"}},{"r":3,"c":4,"v":{"m":"Nice","ct":{"fa":"General","t":"g"},"v":"Nice"}},{"r":3,"c":5,"v":{"m":"2020-09-26","ct":{"fa":"yyyy-MM-dd","t":"d"},"v":44100}},{"r":3,"c":6,"v":{"v":"3114141990091389102","ct":{"fa":"@","t":"s"},"m":"3114141990091389102"}},{"r":3,"c":7,"v":{"v":136787654412,"ct":{"fa":"##0","t":"n"},"m":"136787654412"}},{"r":4,"c":0,"v":{"ct":{"fa":"General","t":"g"},"v":"Doing","m":"Doing"}},{"r":4,"c":1,"v":{"m":"Fail","ct":{"fa":"General","t":"g"},"v":"Fail"}},{"r":4,"c":2,"v":{"v":11,"ct":{"fa":"General","t":"n"},"m":"11"}},{"r":4,"c":3,"v":{"ct":{"fa":"General","t":"g"},"v":"Luckysheet Documentation","m":"Luckysheet Documentation"}},{"r":4,"c":4,"v":{"ct":{"fa":"General","t":"g"},"v":"Morning","m":"Morning"}},{"r":4,"c":5,"v":{"m":"2020-09-27","ct":{"fa":"yyyy-MM-dd","t":"d"},"v":44101}},{"r":4,"c":6,"v":{"v":"31141419900913891X","ct":{"fa":"@","t":"s"},"m":"31141419900913891X"}},{"r":4,"c":7,"v":{"v":49865342456,"ct":{"fa":"General","t":"n"},"m":"49865342456"}},{"r":5,"c":0,"v":{"ct":{"fa":"General","t":"g"},"v":"Develop","m":"Develop"}},{"r":5,"c":1,"v":{"m":"Fail","ct":{"fa":"General","t":"g"},"v":"Fail"}},{"r":5,"c":2,"v":{"v":3,"ct":{"fa":"General","t":"n"},"m":"3"}},{"r":5,"c":3,"v":{"m":"Luckyexcel","ct":{"fa":"General","t":"g"},"v":"Luckyexcel"}},{"r":5,"c":4,"v":{"ct":{"fa":"General","t":"g"},"v":"Tomorrow","m":"Tomorrow"}},{"r":5,"c":5,"v":{"ct":{"fa":"yyyy-MM-dd","t":"d"},"v":44071,"m":"2020-08-28"}},{"r":5,"c":6,"v":{"v":"Number","ct":{"fa":"@","t":"s"},"m":"Number"}},{"r":5,"c":7,"v":{"v":"Number","ct":{"fa":"General","t":"g"},"m":"Number"}},{"r":6,"c":0,"v":{"ct":{"fa":"General","t":"g"},"v":"Done","m":"Done"}},{"r":6,"c":1,"v":{"m":"Pass","ct":{"fa":"General","t":"g"},"v":"Pass"}},{"r":6,"c":2,"v":{"v":0,"ct":{"fa":"General","t":"n"},"m":"0"}},{"r":6,"c":3,"v":{"m":"Luckysheet Online","ct":{"fa":"General","t":"g"},"v":"Luckysheet Online"}},{"r":6,"c":4,"v":{"m":"Three","ct":{"fa":"General","t":"g"},"v":"Three"}},{"r":6,"c":5,"v":{"m":"2020-09-29","ct":{"fa":"yyyy-MM-dd","t":"d"},"v":44103}},{"r":6,"c":6,"v":{"v":"311414199301118910","ct":{"fa":"@","t":"s"},"m":"311414199301118910"}},{"r":6,"c":7,"v":{"v":23309873564,"ct":{"fa":"General","t":"n"},"m":"23309873564"}},{"r":7,"c":8,"v":{"v":null,"ct":{"fa":"General","t":"g"},"bl":1}}],
"row": 84,
"column": 60,
"config": {
"merge": {},
"rowlen": {},
"columnlen": {
"0": 109,
"2": 143,
"3": 200,
"4": 180,
"6": 178,
"7": 125
},
"customWidth": {
"2": 1,
"3": 1,
"4": 1,
"6": 1,
"7": 1
}
},
"luckysheet_select_save": [
{
"left": 963,
"width": 125,
"top": 240,
"height": 19,
"left_move": 963,
"width_move": 125,
"top_move": 240,
"height_move": 19,
"row": [
12,
12
],
"column": [
7,
7
],
"row_focus": 12,
"column_focus": 7
}
],
"dataVerification": {
"1_0": {
"type": "dropdown",
"type2": null,
"value1": "Develop,Fix,Done",
"value2": "",
"checked": false,
"remote": false,
"prohibitInput": false,
"hintShow": false,
"hintText": ""
},
"2_0": {
"type": "dropdown",
"type2": null,
"value1": "Develop,Fix,Done",
"value2": "",
"checked": false,
"remote": false,
"prohibitInput": false,
"hintShow": false,
"hintText": ""
},
"3_0": {
"type": "dropdown",
"type2": null,
"value1": "Develop,Fix,Done",
"value2": "",
"checked": false,
"remote": false,
"prohibitInput": false,
"hintShow": false,
"hintText": ""
},
"4_0": {
"type": "dropdown",
"type2": null,
"value1": "Develop,Fix,Done",
"value2": "",
"checked": false,
"remote": false,
"prohibitInput": false,
"hintShow": false,
"hintText": ""
},
"5_0": {
"type": "dropdown",
"type2": null,
"value1": "Develop,Fix,Done",
"value2": "",
"checked": false,
"remote": false,
"prohibitInput": false,
"hintShow": false,
"hintText": ""
},
"6_0": {
"type": "dropdown",
"type2": null,
"value1": "Develop,Fix,Done",
"value2": "",
"checked": false,
"remote": false,
"prohibitInput": false,
"hintShow": false,
"hintText": ""
},
"1_1": {
"type": "checkbox",
"type2": null,
"value1": "Pass",
"value2": "Fail",
"checked": false,
"remote": true,
"prohibitInput": false,
"hintShow": false,
"hintText": ""
},
"2_1": {
"type": "checkbox",
"type2": null,
"value1": "Pass",
"value2": "Fail",
"checked": true,
"remote": true,
"prohibitInput": false,
"hintShow": false,
"hintText": ""
},
"3_1": {
"type": "checkbox",
"type2": null,
"value1": "Pass",
"value2": "Fail",
"checked": false,
"remote": true,
"prohibitInput": false,
"hintShow": false,
"hintText": ""
},
"4_1": {
"type": "checkbox",
"type2": null,
"value1": "Pass",
"value2": "Fail",
"checked": false,
"remote": true,
"prohibitInput": false,
"hintShow": false,
"hintText": ""
},
"5_1": {
"type": "checkbox",
"type2": null,
"value1": "Pass",
"value2": "Fail",
"checked": false,
"remote": true,
"prohibitInput": false,
"hintShow": false,
"hintText": ""
},
"6_1": {
"type": "checkbox",
"type2": null,
"value1": "Pass",
"value2": "Fail",
"checked": true,
"remote": true,
"prohibitInput": false,
"hintShow": false,
"hintText": ""
},
"1_2": {
"type": "number",
"type2": "bw",
"value1": "1",
"value2": "10",
"checked": false,
"remote": false,
"prohibitInput": true,
"hintShow": false,
"hintText": ""
},
"2_2": {
"type": "number",
"type2": "bw",
"value1": "1",
"value2": "10",
"checked": false,
"remote": false,
"prohibitInput": true,
"hintShow": false,
"hintText": ""
},
"3_2": {
"type": "number",
"type2": "bw",
"value1": "1",
"value2": "10",
"checked": false,
"remote": false,
"prohibitInput": true,
"hintShow": false,
"hintText": ""
},
"4_2": {
"type": "number",
"type2": "bw",
"value1": "1",
"value2": "10",
"checked": false,
"remote": false,
"prohibitInput": true,
"hintShow": false,
"hintText": ""
},
"5_2": {
"type": "number",
"type2": "bw",
"value1": "1",
"value2": "10",
"checked": false,
"remote": false,
"prohibitInput": true,
"hintShow": false,
"hintText": ""
},
"6_2": {
"type": "number",
"type2": "bw",
"value1": "1",
"value2": "10",
"checked": false,
"remote": false,
"prohibitInput": true,
"hintShow": false,
"hintText": ""
},
"1_3": {
"type": "text_content",
"type2": "include",
"value1": "Luckysheet",
"value2": "",
"checked": false,
"remote": false,
"prohibitInput": false,
"hintShow": true,
"hintText": "include Luckysheet"
},
"2_3": {
"type": "text_content",
"type2": "include",
"value1": "Luckysheet",
"value2": "",
"checked": false,
"remote": false,
"prohibitInput": false,
"hintShow": true,
"hintText": "include Luckysheet"
},
"3_3": {
"type": "text_content",
"type2": "include",
"value1": "Luckysheet",
"value2": "",
"checked": false,
"remote": false,
"prohibitInput": false,
"hintShow": true,
"hintText": "include Luckysheet"
},
"4_3": {
"type": "text_content",
"type2": "include",
"value1": "Luckysheet",
"value2": "",
"checked": false,
"remote": false,
"prohibitInput": false,
"hintShow": true,
"hintText": "include Luckysheet"
},
"5_3": {
"type": "text_content",
"type2": "include",
"value1": "Luckysheet",
"value2": "",
"checked": false,
"remote": false,
"prohibitInput": false,
"hintShow": true,
"hintText": "include Luckysheet"
},
"6_3": {
"type": "text_content",
"type2": "include",
"value1": "Luckysheet",
"value2": "",
"checked": false,
"remote": false,
"prohibitInput": false,
"hintShow": true,
"hintText": "include Luckysheet"
},
"1_4": {
"type": "text_length",
"type2": "bw",
"value1": "1",
"value2": "5",
"checked": false,
"remote": false,
"prohibitInput": true,
"hintShow": false,
"hintText": ""
},
"2_4": {
"type": "text_length",
"type2": "bw",
"value1": "1",
"value2": "5",
"checked": false,
"remote": false,
"prohibitInput": true,
"hintShow": false,
"hintText": ""
},
"3_4": {
"type": "text_length",
"type2": "bw",
"value1": "1",
"value2": "5",
"checked": false,
"remote": false,
"prohibitInput": true,
"hintShow": false,
"hintText": ""
},
"4_4": {
"type": "text_length",
"type2": "bw",
"value1": "1",
"value2": "5",
"checked": false,
"remote": false,
"prohibitInput": true,
"hintShow": false,
"hintText": ""
},
"5_4": {
"type": "text_length",
"type2": "bw",
"value1": "1",
"value2": "5",
"checked": false,
"remote": false,
"prohibitInput": true,
"hintShow": false,
"hintText": ""
},
"6_4": {
"type": "text_length",
"type2": "bw",
"value1": "1",
"value2": "5",
"checked": false,
"remote": false,
"prohibitInput": true,
"hintShow": false,
"hintText": ""
},
"1_5": {
"type": "date",
"type2": "bw",
"value1": "2020-09-23",
"value2": "2020-10-10",
"checked": false,
"remote": false,
"prohibitInput": false,
"hintShow": false,
"hintText": ""
},
"2_5": {
"type": "date",
"type2": "bw",
"value1": "2020-09-23",
"value2": "2020-10-10",
"checked": false,
"remote": false,
"prohibitInput": false,
"hintShow": false,
"hintText": ""
},
"3_5": {
"type": "date",
"type2": "bw",
"value1": "2020-09-23",
"value2": "2020-10-10",
"checked": false,
"remote": false,
"prohibitInput": false,
"hintShow": false,
"hintText": ""
},
"4_5": {
"type": "date",
"type2": "bw",
"value1": "2020-09-23",
"value2": "2020-10-10",
"checked": false,
"remote": false,
"prohibitInput": false,
"hintShow": false,
"hintText": ""
},
"5_5": {
"type": "date",
"type2": "bw",
"value1": "2020-09-23",
"value2": "2020-10-10",
"checked": false,
"remote": false,
"prohibitInput": false,
"hintShow": false,
"hintText": ""
},
"6_5": {
"type": "date",
"type2": "bw",
"value1": "2020-09-23",
"value2": "2020-10-10",
"checked": false,
"remote": false,
"prohibitInput": false,
"hintShow": false,
"hintText": ""
},
"1_6": {
"type": "validity",
"type2": "card",
"value1": "",
"value2": "",
"checked": false,
"remote": false,
"prohibitInput": false,
"hintShow": false,
"hintText": ""
},
"2_6": {
"type": "validity",
"type2": "card",
"value1": "",
"value2": "",
"checked": false,
"remote": false,
"prohibitInput": false,
"hintShow": false,
"hintText": ""
},
"3_6": {
"type": "validity",
"type2": "card",
"value1": "",
"value2": "",
"checked": false,
"remote": false,
"prohibitInput": false,
"hintShow": false,
"hintText": ""
},
"4_6": {
"type": "validity",
"type2": "card",
"value1": "",
"value2": "",
"checked": false,
"remote": false,
"prohibitInput": false,
"hintShow": false,
"hintText": ""
},
"5_6": {
"type": "validity",
"type2": "card",
"value1": "",
"value2": "",
"checked": false,
"remote": false,
"prohibitInput": false,
"hintShow": false,
"hintText": ""
},
"6_6": {
"type": "validity",
"type2": "card",
"value1": "",
"value2": "",
"checked": false,
"remote": false,
"prohibitInput": false,
"hintShow": false,
"hintText": ""
},
"1_7": {
"type": "validity",
"type2": "phone",
"value1": "",
"value2": "",
"checked": false,
"remote": false,
"prohibitInput": false,
"hintShow": false,
"hintText": ""
},
"2_7": {
"type": "validity",
"type2": "phone",
"value1": "",
"value2": "",
"checked": false,
"remote": false,
"prohibitInput": false,
"hintShow": false,
"hintText": ""
},
"3_7": {
"type": "validity",
"type2": "phone",
"value1": "",
"value2": "",
"checked": false,
"remote": false,
"prohibitInput": false,
"hintShow": false,
"hintText": ""
},
"4_7": {
"type": "validity",
"type2": "phone",
"value1": "",
"value2": "",
"checked": false,
"remote": false,
"prohibitInput": false,
"hintShow": false,
"hintText": ""
},
"5_7": {
"type": "validity",
"type2": "phone",
"value1": "",
"value2": "",
"checked": false,
"remote": false,
"prohibitInput": false,
"hintShow": false,
"hintText": ""
},
"6_7": {
"type": "validity",
"type2": "phone",
"value1": "",
"value2": "",
"checked": false,
"remote": false,
"prohibitInput": false,
"hintShow": false,
"hintText": ""
}
}
}
// export default sheetDataVerification;
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
@@ -1,189 +0,0 @@
window.sheetPivotTable = {
"name": "PivotTable",
"color": "",
"config": {},
"index": "7",
"chart": [],
"status": 0,
"order": "7",
"column": 18,
"row": 36,
"celldata": [{
"r": 0,
"c": 0,
"v": "count:score"
}, {
"r": 0,
"c": 1,
"v": "science"
}, {
"r": 0,
"c": 2,
"v": "mathematics"
}, {
"r": 0,
"c": 3,
"v": "foreign language"
}, {
"r": 0,
"c": 4,
"v": "English"
}, {
"r": 0,
"c": 5,
"v": "total"
}, {
"r": 1,
"c": 0,
"v": "Alex"
}, {
"r": 1,
"c": 1,
"v": 1
}, {
"r": 1,
"c": 2,
"v": 1
}, {
"r": 1,
"c": 3,
"v": 1
}, {
"r": 1,
"c": 4,
"v": 1
}, {
"r": 1,
"c": 5,
"v": 4
}, {
"r": 2,
"c": 0,
"v": "Joy"
}, {
"r": 2,
"c": 1,
"v": 1
}, {
"r": 2,
"c": 2,
"v": 1
}, {
"r": 2,
"c": 3,
"v": 1
}, {
"r": 2,
"c": 4,
"v": 1
}, {
"r": 2,
"c": 5,
"v": 4
}, {
"r": 3,
"c": 0,
"v": "Tim"
}, {
"r": 3,
"c": 1,
"v": 1
}, {
"r": 3,
"c": 2,
"v": 1
}, {
"r": 3,
"c": 3,
"v": 1
}, {
"r": 3,
"c": 4,
"v": 1
}, {
"r": 3,
"c": 5,
"v": 4
}, {
"r": 4,
"c": 0,
"v": "total"
}, {
"r": 4,
"c": 1,
"v": 3
}, {
"r": 4,
"c": 2,
"v": 3
}, {
"r": 4,
"c": 3,
"v": 3
}, {
"r": 4,
"c": 4,
"v": 3
}, {
"r": 4,
"c": 5,
"v": 12
}],
"ch_width": 4748,
"rh_height": 1790,
"luckysheet_select_save": [{
"row": [0, 0],
"column": [0, 0]
}],
"luckysheet_selection_range": [],
"scrollLeft": 0,
"scrollTop": 0,
"isPivotTable": true,
"pivotTable": {
"pivot_select_save": {
"left": 0,
"width": 73,
"top": 0,
"height": 19,
"left_move": 0,
"width_move": 369,
"top_move": 0,
"height_move": 259,
"row": [0, 12],
"column": [0, 4],
"row_focus": 0,
"column_focus": 0
},
"pivotDataSheetIndex": 6, //The sheet index where the source data is located
"column": [{
"index": 3,
"name": "subject",
"fullname": "subject"
}],
"row": [{
"index": 1,
"name": "student",
"fullname": "student"
}],
"filter": [],
"values": [{
"index": 4,
"name": "score",
"fullname": "count:score",
"sumtype": "COUNTA",
"nameindex": 0
}],
"showType": "column",
"pivotDatas": [
["count:score", "science", "mathematics", "foreign language", "English", "total"],
["Alex", 1, 1, 1, 1, 4],
["Joy", 1, 1, 1, 1, 4],
["Tim", 1, 1, 1, 1, 4],
["total", 3, 3, 3, 3, 12]
],
"drawPivotTable": false,
"pivotTableBoundary": [5, 6]
}
}
// export default sheetPivotTable;
@@ -1,741 +0,0 @@
window.sheetPivotTableData = {
"name": "PivotTableData",
"color": "",
"config": {
"merge": {}
},
"index": "6",
"chart": [],
"status": 0,
"order": "6",
"hide": 0,
"column": 18,
"row": 36,
"celldata": [{
"r": 0,
"c": 0,
"v": {
"m": "Mock test",
"ct": {
"fa": "General",
"t": "g"
},
"v": "Mock test"
}
}, {
"r": 0,
"c": 1,
"v": {
"m": "student",
"ct": {
"fa": "General",
"t": "g"
},
"v": "student"
}
}, {
"r": 0,
"c": 2,
"v": {
"m": "class",
"ct": {
"fa": "General",
"t": "g"
},
"v": "class"
}
}, {
"r": 0,
"c": 3,
"v": {
"m": "subject",
"ct": {
"fa": "General",
"t": "g"
},
"v": "subject"
}
}, {
"r": 0,
"c": 4,
"v": {
"m": "score",
"ct": {
"fa": "General",
"t": "g"
},
"v": "score"
}
}, {
"r": 1,
"c": 0,
"v": {
"m": "first round",
"ct": {
"fa": "General",
"t": "g"
},
"v": "first round"
}
}, {
"r": 1,
"c": 1,
"v": {
"ct": {
"fa": "General",
"t": "g"
},
"v": "Joy",
"m": "Joy"
}
}, {
"r": 1,
"c": 2,
"v": {
"m": "Class one",
"ct": {
"fa": "General",
"t": "g"
},
"v": "Class one"
}
}, {
"r": 1,
"c": 3,
"v": {
"m": "English",
"ct": {
"fa": "General",
"t": "g"
},
"v": "English"
}
}, {
"r": 1,
"c": 4,
"v": {
"v": 96,
"ct": {
"fa": "General",
"t": "n"
},
"m": "96"
}
}, {
"r": 2,
"c": 0,
"v": {
"m": "first round",
"ct": {
"fa": "General",
"t": "g"
},
"v": "first round"
}
}, {
"r": 2,
"c": 1,
"v": {
"ct": {
"fa": "General",
"t": "g"
},
"v": "Joy",
"m": "Joy"
}
}, {
"r": 2,
"c": 2,
"v": {
"m": "Class one",
"ct": {
"fa": "General",
"t": "g"
},
"v": "Class one"
}
}, {
"r": 2,
"c": 3,
"v": {
"m": "mathematics",
"ct": {
"fa": "General",
"t": "g"
},
"v": "mathematics"
}
}, {
"r": 2,
"c": 4,
"v": {
"v": 110,
"ct": {
"fa": "General",
"t": "n"
},
"m": "110"
}
}, {
"r": 3,
"c": 0,
"v": {
"m": "first round",
"ct": {
"fa": "General",
"t": "g"
},
"v": "first round"
}
}, {
"r": 3,
"c": 1,
"v": {
"ct": {
"fa": "General",
"t": "g"
},
"v": "Joy",
"m": "Joy"
}
}, {
"r": 3,
"c": 2,
"v": {
"m": "Class one",
"ct": {
"fa": "General",
"t": "g"
},
"v": "Class one"
}
}, {
"r": 3,
"c": 3,
"v": {
"m": "foreign language",
"ct": {
"fa": "General",
"t": "g"
},
"v": "foreign language"
}
}, {
"r": 3,
"c": 4,
"v": {
"v": 87,
"ct": {
"fa": "General",
"t": "n"
},
"m": "87"
}
}, {
"r": 4,
"c": 0,
"v": {
"m": "first round",
"ct": {
"fa": "General",
"t": "g"
},
"v": "first round"
}
}, {
"r": 4,
"c": 1,
"v": {
"ct": {
"fa": "General",
"t": "g"
},
"v": "Joy",
"m": "Joy"
}
}, {
"r": 4,
"c": 2,
"v": {
"m": "Class one",
"ct": {
"fa": "General",
"t": "g"
},
"v": "Class one"
}
}, {
"r": 4,
"c": 3,
"v": {
"m": "science",
"ct": {
"fa": "General",
"t": "g"
},
"v": "science"
}
}, {
"r": 4,
"c": 4,
"v": {
"v": 266,
"ct": {
"fa": "General",
"t": "n"
},
"m": "266"
}
}, {
"r": 5,
"c": 0,
"v": {
"m": "first round",
"ct": {
"fa": "General",
"t": "g"
},
"v": "first round"
}
}, {
"r": 5,
"c": 1,
"v": {
"ct": {
"fa": "General",
"t": "g"
},
"v": "Tim",
"m": "Tim"
}
}, {
"r": 5,
"c": 2,
"v": {
"m": "Class one",
"ct": {
"fa": "General",
"t": "g"
},
"v": "Class one"
}
}, {
"r": 5,
"c": 3,
"v": {
"m": "English",
"ct": {
"fa": "General",
"t": "g"
},
"v": "English"
}
}, {
"r": 5,
"c": 4,
"v": {
"v": 92,
"ct": {
"fa": "General",
"t": "n"
},
"m": "92"
}
}, {
"r": 6,
"c": 0,
"v": {
"m": "first round",
"ct": {
"fa": "General",
"t": "g"
},
"v": "first round"
}
}, {
"r": 6,
"c": 1,
"v": {
"ct": {
"fa": "General",
"t": "g"
},
"v": "Tim",
"m": "Tim"
}
}, {
"r": 6,
"c": 2,
"v": {
"m": "Class one",
"ct": {
"fa": "General",
"t": "g"
},
"v": "Class one"
}
}, {
"r": 6,
"c": 3,
"v": {
"m": "mathematics",
"ct": {
"fa": "General",
"t": "g"
},
"v": "mathematics"
}
}, {
"r": 6,
"c": 4,
"v": {
"v": 100,
"ct": {
"fa": "General",
"t": "n"
},
"m": "100"
}
}, {
"r": 7,
"c": 0,
"v": {
"m": "first round",
"ct": {
"fa": "General",
"t": "g"
},
"v": "first round"
}
}, {
"r": 7,
"c": 1,
"v": {
"ct": {
"fa": "General",
"t": "g"
},
"v": "Tim",
"m": "Tim"
}
}, {
"r": 7,
"c": 2,
"v": {
"m": "Class one",
"ct": {
"fa": "General",
"t": "g"
},
"v": "Class one"
}
}, {
"r": 7,
"c": 3,
"v": {
"m": "foreign language",
"ct": {
"fa": "General",
"t": "g"
},
"v": "foreign language"
}
}, {
"r": 7,
"c": 4,
"v": {
"v": 90,
"ct": {
"fa": "General",
"t": "n"
},
"m": "90"
}
}, {
"r": 8,
"c": 0,
"v": {
"m": "first round",
"ct": {
"fa": "General",
"t": "g"
},
"v": "first round"
}
}, {
"r": 8,
"c": 1,
"v": {
"ct": {
"fa": "General",
"t": "g"
},
"v": "Tim",
"m": "Tim"
}
}, {
"r": 8,
"c": 2,
"v": {
"m": "Class one",
"ct": {
"fa": "General",
"t": "g"
},
"v": "Class one"
}
}, {
"r": 8,
"c": 3,
"v": {
"m": "science",
"ct": {
"fa": "General",
"t": "g"
},
"v": "science"
}
}, {
"r": 8,
"c": 4,
"v": {
"v": 255,
"ct": {
"fa": "General",
"t": "n"
},
"m": "255"
}
}, {
"r": 9,
"c": 0,
"v": {
"m": "first round",
"ct": {
"fa": "General",
"t": "g"
},
"v": "first round"
}
}, {
"r": 9,
"c": 1,
"v": {
"m": "Alex",
"ct": {
"fa": "General",
"t": "g"
},
"v": "Alex"
}
}, {
"r": 9,
"c": 2,
"v": {
"m": "Class one",
"ct": {
"fa": "General",
"t": "g"
},
"v": "Class one"
}
}, {
"r": 9,
"c": 3,
"v": {
"m": "English",
"ct": {
"fa": "General",
"t": "g"
},
"v": "English"
}
}, {
"r": 9,
"c": 4,
"v": {
"v": 108,
"ct": {
"fa": "General",
"t": "n"
},
"m": "108"
}
}, {
"r": 10,
"c": 0,
"v": {
"m": "first round",
"ct": {
"fa": "General",
"t": "g"
},
"v": "first round"
}
}, {
"r": 10,
"c": 1,
"v": {
"m": "Alex",
"ct": {
"fa": "General",
"t": "g"
},
"v": "Alex"
}
}, {
"r": 10,
"c": 2,
"v": {
"m": "Class one",
"ct": {
"fa": "General",
"t": "g"
},
"v": "Class one"
}
}, {
"r": 10,
"c": 3,
"v": {
"m": "mathematics",
"ct": {
"fa": "General",
"t": "g"
},
"v": "mathematics"
}
}, {
"r": 10,
"c": 4,
"v": {
"v": 117,
"ct": {
"fa": "General",
"t": "n"
},
"m": "117"
}
}, {
"r": 11,
"c": 0,
"v": {
"m": "first round",
"ct": {
"fa": "General",
"t": "g"
},
"v": "first round"
}
}, {
"r": 11,
"c": 1,
"v": {
"m": "Alex",
"ct": {
"fa": "General",
"t": "g"
},
"v": "Alex"
}
}, {
"r": 11,
"c": 2,
"v": {
"m": "Class one",
"ct": {
"fa": "General",
"t": "g"
},
"v": "Class one"
}
}, {
"r": 11,
"c": 3,
"v": {
"m": "foreign language",
"ct": {
"fa": "General",
"t": "g"
},
"v": "foreign language"
}
}, {
"r": 11,
"c": 4,
"v": {
"v": 88,
"ct": {
"fa": "General",
"t": "n"
},
"m": "88"
}
}, {
"r": 12,
"c": 0,
"v": {
"m": "first round",
"ct": {
"fa": "General",
"t": "g"
},
"v": "first round"
}
}, {
"r": 12,
"c": 1,
"v": {
"m": "Alex",
"ct": {
"fa": "General",
"t": "g"
},
"v": "Alex"
}
}, {
"r": 12,
"c": 2,
"v": {
"m": "Class one",
"ct": {
"fa": "General",
"t": "g"
},
"v": "Class one"
}
}, {
"r": 12,
"c": 3,
"v": {
"m": "science",
"ct": {
"fa": "General",
"t": "g"
},
"v": "science"
}
}, {
"r": 12,
"c": 4,
"v": {
"v": 278,
"ct": {
"fa": "General",
"t": "n"
},
"m": "278"
}
}],
"ch_width": 4748,
"rh_height": 1790,
"luckysheet_select_save": [{
"row": [0, 0],
"column": [0, 0]
}],
"luckysheet_selection_range": [],
"scrollLeft": 0,
"scrollTop": 0
}
// export default sheetPivotTableData;
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,188 @@
(function (global, factory) {
if (typeof define === "function" && define.amd) {
define([], factory);
} else if (typeof exports !== "undefined") {
factory();
} else {
var mod = {
exports: {}
};
factory();
global.FileSaver = mod.exports;
}
})(this, function () {
"use strict";
/*
* FileSaver.js
* A saveAs() FileSaver implementation.
*
* By Eli Grey, http://eligrey.com
*
* License : https://github.com/eligrey/FileSaver.js/blob/master/LICENSE.md (MIT)
* source : http://purl.eligrey.com/github/FileSaver.js
*/
// The one and only way of getting global scope in all environments
// https://stackoverflow.com/q/3277182/1008999
var _global = typeof window === 'object' && window.window === window ? window : typeof self === 'object' && self.self === self ? self : typeof global === 'object' && global.global === global ? global : void 0;
function bom(blob, opts) {
if (typeof opts === 'undefined') opts = {
autoBom: false
};else if (typeof opts !== 'object') {
console.warn('Deprecated: Expected third argument to be a object');
opts = {
autoBom: !opts
};
} // prepend BOM for UTF-8 XML and text/* types (including HTML)
// note: your browser will automatically convert UTF-16 U+FEFF to EF BB BF
if (opts.autoBom && /^\s*(?:text\/\S*|application\/xml|\S*\/\S*\+xml)\s*;.*charset\s*=\s*utf-8/i.test(blob.type)) {
return new Blob([String.fromCharCode(0xFEFF), blob], {
type: blob.type
});
}
return blob;
}
function download(url, name, opts) {
var xhr = new XMLHttpRequest();
xhr.open('GET', url);
xhr.responseType = 'blob';
xhr.onload = function () {
saveAs(xhr.response, name, opts);
};
xhr.onerror = function () {
console.error('could not download file');
};
xhr.send();
}
function corsEnabled(url) {
var xhr = new XMLHttpRequest(); // use sync to avoid popup blocker
xhr.open('HEAD', url, false);
try {
xhr.send();
} catch (e) {}
return xhr.status >= 200 && xhr.status <= 299;
} // `a.click()` doesn't work for all browsers (#465)
function click(node) {
try {
node.dispatchEvent(new MouseEvent('click'));
} catch (e) {
var evt = document.createEvent('MouseEvents');
evt.initMouseEvent('click', true, true, window, 0, 0, 0, 80, 20, false, false, false, false, 0, null);
node.dispatchEvent(evt);
}
} // Detect WebView inside a native macOS app by ruling out all browsers
// We just need to check for 'Safari' because all other browsers (besides Firefox) include that too
// https://www.whatismybrowser.com/guides/the-latest-user-agent/macos
var isMacOSWebView = _global.navigator && /Macintosh/.test(navigator.userAgent) && /AppleWebKit/.test(navigator.userAgent) && !/Safari/.test(navigator.userAgent);
var saveAs = _global.saveAs || ( // probably in some web worker
typeof window !== 'object' || window !== _global ? function saveAs() {}
/* noop */
// Use download attribute first if possible (#193 Lumia mobile) unless this is a macOS WebView
: 'download' in HTMLAnchorElement.prototype && !isMacOSWebView ? function saveAs(blob, name, opts) {
var URL = _global.URL || _global.webkitURL;
var a = document.createElement('a');
name = name || blob.name || 'download';
a.download = name;
a.rel = 'noopener'; // tabnabbing
// TODO: detect chrome extensions & packaged apps
// a.target = '_blank'
if (typeof blob === 'string') {
// Support regular links
a.href = blob;
if (a.origin !== location.origin) {
corsEnabled(a.href) ? download(blob, name, opts) : click(a, a.target = '_blank');
} else {
click(a);
}
} else {
// Support blobs
a.href = URL.createObjectURL(blob);
setTimeout(function () {
URL.revokeObjectURL(a.href);
}, 4E4); // 40s
setTimeout(function () {
click(a);
}, 0);
}
} // Use msSaveOrOpenBlob as a second approach
: 'msSaveOrOpenBlob' in navigator ? function saveAs(blob, name, opts) {
name = name || blob.name || 'download';
if (typeof blob === 'string') {
if (corsEnabled(blob)) {
download(blob, name, opts);
} else {
var a = document.createElement('a');
a.href = blob;
a.target = '_blank';
setTimeout(function () {
click(a);
});
}
} else {
navigator.msSaveOrOpenBlob(bom(blob, opts), name);
}
} // Fallback to using FileReader and a popup
: function saveAs(blob, name, opts, popup) {
// Open a popup immediately do go around popup blocker
// Mostly only available on user interaction and the fileReader is async so...
popup = popup || open('', '_blank');
if (popup) {
popup.document.title = popup.document.body.innerText = 'downloading...';
}
if (typeof blob === 'string') return download(blob, name, opts);
var force = blob.type === 'application/octet-stream';
var isSafari = /constructor/i.test(_global.HTMLElement) || _global.safari;
var isChromeIOS = /CriOS\/[\d]+/.test(navigator.userAgent);
if ((isChromeIOS || force && isSafari || isMacOSWebView) && typeof FileReader !== 'undefined') {
// Safari doesn't allow downloading of blob URLs
var reader = new FileReader();
reader.onloadend = function () {
var url = reader.result;
url = isChromeIOS ? url : url.replace(/^data:[^;]*;/, 'data:attachment/file;');
if (popup) popup.location.href = url;else location = url;
popup = null; // reverse-tabnabbing #460
};
reader.readAsDataURL(blob);
} else {
var URL = _global.URL || _global.webkitURL;
var url = URL.createObjectURL(blob);
if (popup) popup.location = url;else location.href = url;
popup = null; // reverse-tabnabbing #460
setTimeout(function () {
URL.revokeObjectURL(url);
}, 4E4); // 40s
}
});
_global.saveAs = saveAs.saveAs = saveAs;
if (typeof module !== 'undefined') {
module.exports = saveAs;
}
});
File diff suppressed because one or more lines are too long
@@ -0,0 +1,162 @@
#luckysheet-vchart-setting-dialog {
padding: 10px;
position: absolute;
overflow: hidden;
right: 0;
top: 0;
width: 320px;
height: calc(100% - 20px);
box-shadow: 0 8px 10px -5px rgba(0, 0, 0, 0.2), 0 16px 24px 2px rgba(0, 0, 0, 0.14), 0 6px 30px 5px rgba(0, 0, 0, 0.12);
z-index: 9999;
background-color: #fff;
color: #444;
display: flex;
flex-direction: column;
}
.luckysheet-vchart-setting-dialog-title {
height: 32px;
display: flex;
align-items: center;
justify-content: space-between;
/* border-bottom: solid 1px #dadce0; */
padding-bottom: 10px;
}
#luckysheet-vchart-setting-dialog-close {
cursor: pointer;
width: 20px;
display: flex;
align-items: center;
justify-content: center;
}
.luckysheet-vchart-setting-dialog-footer {
height: 32px;
display: flex;
align-items: center;
justify-content: flex-end;
}
.luckysheet-vchart-setting-dialog-footer span {
margin-left: 10px;
width: 75px;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
white-space: nowrap;
cursor: pointer;
background: #fff;
border: 1px solid #dcdfe6;
color: #606266;
text-align: center;
box-sizing: border-box;
outline: none;
transition: 0.1s;
font-weight: 500;
font-size: 14px;
border-radius: 4px;
}
.luckysheet-vchart-setting-dialog-footer .cancel:hover {
color: var(--luckysheet-main-color);
background-color: var(--luckysheet-main-color-a2);
border-color: transparent;
}
.luckysheet-vchart-setting-dialog-footer .confirm {
background-color: var(--luckysheet-main-color-a8);
border-color: transparent;
color: #fff;
}
.luckysheet-vchart-setting-dialog-footer .confirm:hover {
/* color: var(--luckysheet-main-color); */
}
/* 内容区 */
.luckysheet-vchart-setting-dialog-body {
height: calc(100% - 32px - 32px - 10px);
padding: 10px 0;
overflow: hidden;
}
.luckysheet-vchart-setting-dialog-body-tabs {
height: 30px;
border-bottom: solid #dadce0 2px;
}
.luckysheet-vchart-setting-dialog-body-tabs .tab {
display: inline-block;
height: 100%;
padding: 0 10px;
cursor: pointer;
transition: all 0.3s;
}
.luckysheet-vchart-setting-dialog-body-tabs .tab:hover,
.luckysheet-vchart-setting-dialog-body-tabs .active {
border-bottom: solid 2px var(--luckysheet-main-color);
color: var(--luckysheet-main-color);
}
.luckysheet-vchart-setting-dialog-body-content {
padding: 10px;
height: calc(100% - 30px - 20px);
overflow: hidden;
overflow-y: auto;
}
/* 滚动条样式 */
.luckysheet-vchart-setting-dialog-body-content::-webkit-scrollbar {
width: 4px;
}
/* 滑块样式 */
.luckysheet-vchart-setting-dialog-body-content::-webkit-scrollbar-thumb {
background-color: var(--luckysheet-main-color);
border-radius: 2px;
}
/* 滚动条轨道样式 */
.luckysheet-vchart-setting-dialog-body-content::-webkit-scrollbar-track {
background-color: #f2f2f2;
border-radius: 2px;
}
.luckysheet-vchart-setting-dialog-body-content .vchart-type-item {
width: 48%;
user-select: none;
display: inline-block;
box-shadow: 0 6px 20px var(--luckysheet-main-color-a1);
border-radius: 8px;
overflow: hidden;
border: solid 2px transparent;
transition: all 0.3s;
cursor: pointer;
}
.luckysheet-vchart-setting-dialog-body-content .tips {
user-select: none;
font-size: 16px;
}
.luckysheet-vchart-setting-dialog-body-content .vchart-type-item:hover {
border-color: var(--luckysheet-main-color);
}
.luckysheet-vchart-setting-dialog-body-content .vchart-type-item img {
width: 100%;
}
.luckysheet-vchart-setting-dialog-body-content .vchart-type-item div {
text-align: center;
width: 100%;
background: linear-gradient(0deg, #f6f8fe00, #f6f8fe);
height: 32px;
line-height: 32px;
font-size: 12px;
line-height: 32px;
letter-spacing: 0.639896px;
color: #000000b3;
}
/* style content */
.luckysheet-vchart-setting-dialog-body-content .vchart-style-item {
user-select: none;
display: flex;
align-items: center;
}
.luckysheet-vchart-setting-dialog-body-content .vchart-style-item-label {
padding-right: 10px;
font-size: 12px;
color: #909399;
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
@@ -1,46 +0,0 @@
.luckysheet-print span[role="heading"] {
font-size: 30px;
font-weight: bold;
}
.luckysheet-print-suggest {
font-size: 12px;
}
.luckysheet-print-title {
font-weight: bold;
font-size: 18px;
}
.luckysheet-print-radio {
display: flex;
}
.luckysheet-print-radio > div {
width: 50%;
}
.luckysheet-print select {
height: 30px;
}
.luckysheet-print .luckysheet-modal-dialog-buttons {
display: flex;
flex-direction: row-reverse;
}
.luckysheet-print-box canvas {
display: block;
}
@media print {
:not(html, head, body, .luckysheet-print-preview, .luckysheet-print-preview *) {
display: none;
}
.luckysheet-print-break {
page-break-after: always;
}
#print-layout-options {
display: none;
}
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,18 @@
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
import { createSupabaseRouteClient } from "@/lib/supabase/server";
export async function POST(request: NextRequest) {
const supabase = await createSupabaseRouteClient();
const { event, session } = await request.json();
if (event === "SIGNED_IN" || event === "TOKEN_REFRESHED") {
await supabase.auth.setSession(session);
}
if (event === "SIGNED_OUT") {
await supabase.auth.signOut();
}
return NextResponse.json({ success: true });
}
@@ -0,0 +1,34 @@
"use server";
import { NextResponse } from "next/server";
import { createSupabaseRouteClient } from "@/lib/supabase/server";
export async function GET(request: Request) {
const url = new URL(request.url);
const gridKey = url.searchParams.get("gridKey");
if (!gridKey) {
return NextResponse.json({ code: 400, msg: "gridKey 参数缺失" }, { status: 400 });
}
const supabase = await createSupabaseRouteClient();
const { data, error } = await supabase
.from("document_tables")
.select("id,title,grid_key")
.eq("grid_key", gridKey)
.single();
if (error || !data) {
return NextResponse.json({ code: 404, msg: "未查询到相关数据" }, { status: 404 });
}
return NextResponse.json({
code: 200,
msg: "ok",
data: {
title: data.title ?? "未命名工作簿",
lang: "zh",
gridKey: data.grid_key,
},
});
}
@@ -0,0 +1,48 @@
"use server";
import { NextResponse } from "next/server";
import { createDefaultTableSnapshot } from "@/lib/online-table";
import { createSupabaseRouteClient } from "@/lib/supabase/server";
import type { DocumentTableSnapshot, TableSchema } from "@/types/online-table";
const fallbackSchema: TableSchema = {
columns: [],
frozenRowCount: 0,
frozenColCount: 0,
};
export async function POST(request: Request) {
const url = new URL(request.url);
const gridKey = url.searchParams.get("gridKey");
if (!gridKey) {
return NextResponse.json({ code: 400, msg: "gridKey 参数缺失" }, { status: 400 });
}
const supabase = await createSupabaseRouteClient();
const { data, error } = await supabase
.from("document_tables")
.select("schema,snapshot")
.eq("grid_key", gridKey)
.single();
if (error || !data) {
return NextResponse.json({ code: 404, msg: "未查询到相关数据" }, { status: 404 });
}
const snapshot = (data.snapshot as DocumentTableSnapshot | null) ?? null;
const schema = (data.schema as TableSchema | null) ?? fallbackSchema;
let payload: unknown[] = [];
if (snapshot?.luckysheet && Array.isArray(snapshot.luckysheet)) {
payload = snapshot.luckysheet;
} else {
const defaultSnapshot = createDefaultTableSnapshot(schema);
payload = defaultSnapshot.luckysheet ?? [];
}
const body = JSON.stringify(payload);
return new NextResponse(body, {
headers: { "Content-Type": "text/plain; charset=utf-8" },
});
}
@@ -0,0 +1,42 @@
"use server";
import { Buffer } from "node:buffer";
import { randomUUID } from "node:crypto";
import { NextResponse } from "next/server";
import { createSupabaseRouteClient } from "@/lib/supabase/server";
const BUCKET = "media";
const DIRECTORY = "luckysheet";
export async function POST(request: Request) {
const supabase = await createSupabaseRouteClient();
const formData = await request.formData();
const file = formData.get("image");
if (!(file instanceof File)) {
return NextResponse.json({ code: 400, msg: "缺少 image 文件" }, { status: 400 });
}
const arrayBuffer = await file.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
const extension = file.name?.split(".").pop() || "bin";
const objectPath = `${DIRECTORY}/${randomUUID()}.${extension}`;
const { error: uploadError } = await supabase.storage
.from(BUCKET)
.upload(objectPath, buffer, { contentType: file.type || "application/octet-stream", upsert: false });
if (uploadError) {
return NextResponse.json({ code: 500, msg: "上传失败", detail: uploadError.message }, { status: 500 });
}
const {
data: { publicUrl },
} = supabase.storage.from(BUCKET).getPublicUrl(objectPath);
return NextResponse.json({
code: 200,
msg: "ok",
url: publicUrl,
});
}
@@ -98,7 +98,7 @@ export async function PATCH(request: Request, context: RouteContext) {
try {
const { data: tableMeta, error: metaError } = await supabase
.from("document_tables")
.select("id, workspace_id, document_id")
.select("id, workspace_id, document_id, grid_key")
.eq("id", tableId)
.single();
+10
View File
@@ -1,5 +1,6 @@
import { redirect } from "next/navigation";
import { createSupabaseServerClient } from "@/lib/supabase/server";
import { ensureDefaultWorkspace, resolveActiveWorkspaceId } from "@/lib/workspaces";
export default async function Home() {
const supabase = await createSupabaseServerClient();
@@ -11,10 +12,18 @@ export default async function Home() {
redirect("/login");
}
await ensureDefaultWorkspace(supabase, session.user.id, session.user.email ?? "我的空间");
const workspaceId = await resolveActiveWorkspaceId(supabase, session.user.id);
if (!workspaceId) {
redirect("/login");
}
const { data: firstDoc } = await supabase
.from("documents")
.select("id")
.eq("user_id", session.user.id)
.eq("workspace_id", workspaceId)
.order("created_at", { ascending: true })
.limit(1)
.maybeSingle();
@@ -27,6 +36,7 @@ export default async function Home() {
.from("documents")
.insert({
user_id: session.user.id,
workspace_id: workspaceId,
title: "新页面",
content: {},
})
@@ -0,0 +1,23 @@
import HeadlessTableViewer from "@/components/online-table/HeadlessTableViewer";
interface TableViewerPageProps {
params: Promise<{ tableId: string }>;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
searchParams: Promise<Record<string, any>>;
}
export const dynamic = "force-dynamic";
export default async function TableViewerPage({ params, searchParams }: TableViewerPageProps) {
const resolvedParams = await params;
const resolvedSearch = await searchParams;
const tableId = resolvedParams.tableId;
const embedMode = resolvedSearch?.embed === "1";
return (
<div className={embedMode ? "min-h-screen bg-transparent" : "min-h-screen bg-white"}>
<HeadlessTableViewer tableId={tableId} embed={embedMode} />
</div>
);
}
@@ -170,6 +170,7 @@ export function BlockNoteEditor({
const [isSaving, setIsSaving] = useState(false);
const [tocEntries, setTocEntries] = useState<TocEntry[]>([]);
const [fullScreenTableId, setFullScreenTableId] = useState<string | null>(null);
const isFullScreenTableOpen = fullScreenTableId !== null;
const openReferencePalette = useSearchPaletteStore((state) => state.openReference);
const registerEditorBridge = useEditorBridgeStore((state) => state.registerBridge);
@@ -280,6 +281,7 @@ export function BlockNoteEditor({
const blocknoteClass = cn(
"wolai-editor min-h-full",
pageOptions.showStructure && "wolai-editor-show-structure",
isFullScreenTableOpen && "pointer-events-none select-none",
);
const buildDocumentPath = (documentId: string): string => {
@@ -575,11 +577,13 @@ const computeDocumentStats = (blocks: Block<CustomBlockSchema>[]): DocumentStats
editable={!pageOptions.protectEditing}
className={blocknoteClass}
>
<SideMenuController
sideMenu={(props: SideMenuProps<CustomBlockSchema>) => (
<CustomSideMenu {...props} currentDocumentId={documentId} />
)}
/>
{!isFullScreenTableOpen && (
<SideMenuController
sideMenu={(props: SideMenuProps<CustomBlockSchema>) => (
<CustomSideMenu {...props} currentDocumentId={documentId} />
)}
/>
)}
<CustomSlashMenu editor={editor} currentDocumentId={documentId} />
</BlockNoteView>
<div className="pointer-events-none absolute right-4 top-3 text-xs text-gray-400">
@@ -2,13 +2,42 @@
import { BlockNoteEditor, Block } from "@blocknote/core";
import { createReactBlockSpec } from "@blocknote/react";
import { OnlineTableBlockProps } from "@/types/online-table";
import { Table } from "lucide-react";
import React, { useCallback } from "react";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import type { CustomBlockSchema } from "../schema";
import CompactTablePreview from "@/components/online-table/CompactTablePreview";
import { useEditorBridgeStore } from "@/store/editor-bridge";
const DEFAULT_WIDTH = 960;
const DEFAULT_HEIGHT = 520;
const MIN_WIDTH = 420;
const MAX_WIDTH = 1400;
const MIN_HEIGHT = 320;
const MAX_HEIGHT = 900;
type ResizeHandle =
| "left"
| "right"
| "top"
| "bottom"
| "top-left"
| "top-right"
| "bottom-left"
| "bottom-right";
const handleMapping: Record<
ResizeHandle,
{ horizontal?: "left" | "right"; vertical?: "top" | "bottom" }
> = {
left: { horizontal: "left" },
right: { horizontal: "right" },
top: { vertical: "top" },
bottom: { vertical: "bottom" },
"top-left": { horizontal: "left", vertical: "top" },
"top-right": { horizontal: "right", vertical: "top" },
"bottom-left": { horizontal: "left", vertical: "bottom" },
"bottom-right": { horizontal: "right", vertical: "bottom" },
};
// 占位符组件:在紧凑模式下渲染表格块
const OnlineTableBlockComponent = ({
block,
@@ -19,6 +48,34 @@ const OnlineTableBlockComponent = ({
}) => {
const { tableId } = block.props;
const openTableFullScreen = useEditorBridgeStore((state) => state.bridge?.openTableFullScreen);
const storedWidth = typeof block.props.width === "number" ? block.props.width : undefined;
const storedHeight = typeof block.props.height === "number" ? block.props.height : undefined;
const [activeHandle, setActiveHandle] = useState<ResizeHandle | null>(null);
const [draftSize, setDraftSize] = useState({
width: storedWidth ?? DEFAULT_WIDTH,
height: storedHeight ?? DEFAULT_HEIGHT,
});
useEffect(() => {
setDraftSize({
width: storedWidth ?? DEFAULT_WIDTH,
height: storedHeight ?? DEFAULT_HEIGHT,
});
}, [storedWidth, storedHeight]);
const commitSize = useCallback(
(next: { width: number; height: number }) => {
setDraftSize(next);
editor.updateBlock(block, {
props: {
...block.props,
width: next.width,
height: next.height,
},
});
},
[block, block.props, editor],
);
// 阶段三:实现双击/按钮进入全屏编辑
const handleFullScreen = () => {
@@ -33,9 +90,151 @@ const OnlineTableBlockComponent = ({
editor.removeBlocks([block.id]);
}, [block.id, editor]);
const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value));
const startResize = useCallback(
(handle: ResizeHandle) => (event: React.MouseEvent) => {
event.preventDefault();
event.stopPropagation();
const startX = event.clientX;
const startY = event.clientY;
const startWidth = draftSize.width;
const startHeight = draftSize.height;
let nextWidth = startWidth;
let nextHeight = startHeight;
const axes = handleMapping[handle];
setActiveHandle(handle);
document.body.style.userSelect = "none";
const cursor =
axes.horizontal && axes.vertical
? axes.horizontal === "left"
? axes.vertical === "top"
? "nwse-resize"
: "nesw-resize"
: axes.vertical === "top"
? "nesw-resize"
: "nwse-resize"
: axes.horizontal
? "ew-resize"
: "ns-resize";
document.body.style.cursor = cursor;
const handleMove = (moveEvent: MouseEvent) => {
const deltaX = moveEvent.clientX - startX;
const deltaY = moveEvent.clientY - startY;
if (axes.horizontal === "left") {
nextWidth = clamp(startWidth - deltaX, MIN_WIDTH, MAX_WIDTH);
} else if (axes.horizontal === "right") {
nextWidth = clamp(startWidth + deltaX, MIN_WIDTH, MAX_WIDTH);
} else {
nextWidth = startWidth;
}
if (axes.vertical === "top") {
nextHeight = clamp(startHeight - deltaY, MIN_HEIGHT, MAX_HEIGHT);
} else if (axes.vertical === "bottom") {
nextHeight = clamp(startHeight + deltaY, MIN_HEIGHT, MAX_HEIGHT);
} else {
nextHeight = startHeight;
}
setDraftSize({
width: nextWidth,
height: nextHeight,
});
};
const handleUp = () => {
window.removeEventListener("mousemove", handleMove);
window.removeEventListener("mouseup", handleUp);
document.body.style.userSelect = "";
document.body.style.cursor = "";
setActiveHandle(null);
commitSize({
width: Math.round(nextWidth),
height: Math.round(nextHeight),
});
};
window.addEventListener("mousemove", handleMove);
window.addEventListener("mouseup", handleUp);
},
[commitSize, draftSize.height, draftSize.width],
);
const size = useMemo(
() => ({
width: clamp(draftSize.width, MIN_WIDTH, MAX_WIDTH),
height: clamp(draftSize.height, MIN_HEIGHT, MAX_HEIGHT),
}),
[draftSize.height, draftSize.width],
);
const handleClass = (handle: ResizeHandle) =>
`wolai-table-resize-handle wolai-table-resize-handle--${handle} ${
activeHandle === handle ? "is-dragging" : ""
}`;
return (
<div className="w-full">
<CompactTablePreview tableId={tableId} onFullScreen={handleFullScreen} onDelete={handleDelete} />
<div className="w-full overflow-auto" contentEditable={false}>
<div
className="online-table-block group relative mx-auto"
style={{ width: size.width, minWidth: MIN_WIDTH }}
>
<CompactTablePreview
tableId={tableId}
onFullScreen={handleFullScreen}
onDelete={handleDelete}
height={size.height}
/>
<button
type="button"
aria-label="向左拖拽以调整宽度"
className={handleClass("left")}
onMouseDown={startResize("left")}
/>
<button
type="button"
aria-label="向右拖拽以调整宽度"
className={handleClass("right")}
onMouseDown={startResize("right")}
/>
<button
type="button"
aria-label="向上拖拽以调整高度"
className={handleClass("top")}
onMouseDown={startResize("top")}
/>
<button
type="button"
aria-label="向下拖拽以调整高度"
className={handleClass("bottom")}
onMouseDown={startResize("bottom")}
/>
<button
type="button"
aria-label="拖拽以调整宽高"
className={handleClass("top-left")}
onMouseDown={startResize("top-left")}
/>
<button
type="button"
aria-label="拖拽以调整宽高"
className={handleClass("top-right")}
onMouseDown={startResize("top-right")}
/>
<button
type="button"
aria-label="拖拽以调整宽高"
className={handleClass("bottom-left")}
onMouseDown={startResize("bottom-left")}
/>
<button
type="button"
aria-label="拖拽以调整宽高"
className={handleClass("bottom-right")}
onMouseDown={startResize("bottom-right")}
/>
</div>
</div>
);
};
@@ -47,6 +246,8 @@ export const onlineTableBlock = createReactBlockSpec(
propSchema: {
tableId: { default: "new-table-id" }, // 应该在创建时被覆盖
title: { default: "未命名表格" },
width: { default: DEFAULT_WIDTH },
height: { default: DEFAULT_HEIGHT },
},
content: "inline", // 允许内联内容,但通常表格块不会有太多内联内容
},
@@ -1,19 +1,15 @@
"use client";
import React, { useEffect, useState, useMemo, useCallback } from "react";
import { DocumentTable, TableColumn, TableRowData } from "@/types/online-table";
import {
DEFAULT_TABLE_COLUMNS,
DEFAULT_TABLE_ROWS,
deleteOnlineTable,
getDocumentTable,
} from "@/lib/online-table";
import { Loader2, Table, Maximize2, Trash2 } from "lucide-react";
import type { DocumentTable } from "@/types/online-table";
import { deleteOnlineTable, getDocumentTable } from "@/lib/online-table";
import { Loader2, Table as TableIcon, Maximize2, Trash2, RotateCw } from "lucide-react";
interface CompactTablePreviewProps {
tableId: string;
onFullScreen: () => void;
onDelete?: () => void;
height?: number;
}
const useTableData = (tableId: string) => {
@@ -24,130 +20,55 @@ const useTableData = (tableId: string) => {
const refresh = useCallback(() => setVersion((prev) => prev + 1), []);
useEffect(() => {
let aborted = false;
let canceled = false;
setIsLoading(true);
getDocumentTable(tableId)
.then((data) => {
if (aborted) return;
setTable({ ...data, title: data.title || "未命名表格" });
if (!canceled) {
setTable({ ...data, title: data.title || "未命名表格" });
}
})
.catch((err) => {
console.error("Failed to load table:", err);
if (aborted) return;
setTable(null);
.catch((error) => {
console.error("Failed to load table:", error);
if (!canceled) {
setTable(null);
}
})
.finally(() => {
if (!aborted) {
if (!canceled) {
setIsLoading(false);
}
});
return () => {
aborted = true;
canceled = true;
};
}, [tableId, version]);
return { table, isLoading, refresh };
};
const pickCellDisplayValue = (cell: any) => {
if (!cell) return undefined;
if (cell.m !== undefined && cell.m !== null) return cell.m;
if (cell.v?.m !== undefined && cell.v?.m !== null) return cell.v.m;
if (cell.v?.v !== undefined && cell.v?.v !== null) return cell.v.v;
if (cell.v !== undefined && cell.v !== null && typeof cell.v !== "object") return cell.v;
if (cell.w !== undefined && cell.w !== null) return cell.w;
return undefined;
};
const deriveRowsFromLuckysheet = (luckysheetData: any, columns: TableColumn[]): TableRowData[] => {
const sheets = Array.isArray(luckysheetData) ? luckysheetData : [];
const sheet = sheets[0];
if (!sheet) return [];
const columnIds = columns.length > 0
? columns.map((col) => col.id)
: Array.from({ length: DEFAULT_TABLE_COLUMNS }, (_, index) => `col${index + 1}`);
const rows: TableRowData[] = [];
const dataGrid = Array.isArray(sheet.data) ? sheet.data : [];
dataGrid.forEach((rowData: any[], rowIndex: number) => {
if (!Array.isArray(rowData)) return;
const row: TableRowData = {};
let hasValue = false;
columnIds.forEach((colId, colIndex) => {
const value = pickCellDisplayValue(rowData[colIndex]);
if (value !== undefined && value !== null && value !== "") {
row[colId] = value;
hasValue = true;
}
});
if (hasValue) rows.push(row);
});
if (rows.length === 0 && Array.isArray(sheet.celldata)) {
const map = new Map<number, TableRowData>();
sheet.celldata.forEach((cell: { r: number; c: number; v: unknown }) => {
const value = pickCellDisplayValue(cell?.v ?? cell);
if (value === undefined || value === null || value === "") return;
const existing = map.get(cell.r) ?? {};
existing[columnIds[cell.c] ?? `col${cell.c + 1}`] = value;
map.set(cell.r, existing);
});
Array.from(map.entries())
.sort(([a], [b]) => a - b)
.forEach(([, row]) => rows.push(row));
}
return rows;
};
const renderCellContent = (column: TableColumn, value: any) => {
if (value === undefined || value === null || value === "") {
return "";
}
if (column.type === "select") {
const option = column.options?.find((opt) => opt.value === value);
if (option) {
return (
<span className="px-2 py-0.5 text-xs font-medium rounded-full" style={{ backgroundColor: option.color, color: "white" }}>
{value}
</span>
);
}
}
return String(value);
};
const CompactTablePreview: React.FC<CompactTablePreviewProps> = ({ tableId, onFullScreen, onDelete }) => {
const CompactTablePreview: React.FC<CompactTablePreviewProps> = ({
tableId,
onFullScreen,
onDelete,
height,
}) => {
const { table, isLoading, refresh } = useTableData(tableId);
const [visibleRows, setVisibleRows] = useState(5);
const [visibleCols, setVisibleCols] = useState(5);
const [iframeVersion, setIframeVersion] = useState(0);
const [iframeLoading, setIframeLoading] = useState(true);
const schemaColumns = useMemo(
() => Array.isArray(table?.schema?.columns) ? table!.schema.columns : [],
[table],
const iframeSrc = useMemo(
() => `/tables/${tableId}/view?embed=1&v=${iframeVersion}`,
[tableId, iframeVersion],
);
const rows: TableRowData[] = useMemo(() => {
if (!table) return [];
if (Array.isArray(table.snapshot?.rows) && table.snapshot.rows.length > 0) {
return table.snapshot.rows as TableRowData[];
}
if ((table.snapshot as { luckysheet?: unknown })?.luckysheet) {
return deriveRowsFromLuckysheet(
(table.snapshot as { luckysheet?: unknown }).luckysheet,
schemaColumns,
);
}
return [];
}, [schemaColumns, table]);
useEffect(() => {
const handleSaved = (event: Event) => {
const detail = (event as CustomEvent<{ tableId?: string }>).detail;
if (detail?.tableId === tableId) {
refresh();
setIframeVersion((value) => value + 1);
setIframeLoading(true);
}
};
const handleDeleted = (event: Event) => {
@@ -164,65 +85,6 @@ const CompactTablePreview: React.FC<CompactTablePreviewProps> = ({ tableId, onFu
};
}, [refresh, tableId]);
const hasData = rows.length > 0 && schemaColumns.length > 0;
const totalRowsAvailable = hasData ? rows.length : DEFAULT_TABLE_ROWS;
const totalColsAvailable = hasData ? schemaColumns.length : DEFAULT_TABLE_COLUMNS;
useEffect(() => {
const maxRows = Math.max(5, totalRowsAvailable);
const maxCols = Math.max(5, totalColsAvailable);
if (visibleRows > maxRows) {
setVisibleRows(maxRows);
} else if (visibleRows < 5) {
setVisibleRows(5);
}
if (visibleCols > maxCols) {
setVisibleCols(maxCols);
} else if (visibleCols < 5) {
setVisibleCols(5);
}
}, [totalRowsAvailable, totalColsAvailable, visibleRows, visibleCols]);
const columns = useMemo(() => {
if (schemaColumns.length > 0) {
return schemaColumns.slice(0, visibleCols);
}
const placeholderCount = Math.max(visibleCols, 5);
return Array.from({ length: placeholderCount }, (_, index) => ({
id: `placeholder_col_${index}`,
name: "",
type: "text" as const,
width: 100,
}));
}, [schemaColumns, visibleCols]);
const rowsToDisplay = useMemo(() => {
if (rows.length > 0) {
return rows.slice(0, visibleRows);
}
const placeholderCount = Math.max(visibleRows, 5);
return Array.from({ length: placeholderCount }, (_, index) => ({ id: `placeholder_row_${index}` }));
}, [rows, visibleRows]);
const headerCells = useMemo(() => {
const cells = [
<th key="row-actions" className="w-8" aria-label="行操作列" />,
];
columns.forEach((col) => {
cells.push(
<th
key={col.id}
style={{ width: col.width ?? 100 }}
className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider whitespace-nowrap relative group"
>
{col.name}
</th>,
);
});
return cells;
}, [columns]);
const suppressEditorEvents = useCallback((event: React.MouseEvent) => {
event.stopPropagation();
}, []);
@@ -240,9 +102,17 @@ const CompactTablePreview: React.FC<CompactTablePreviewProps> = ({ tableId, onFu
}
}, [onDelete, tableId]);
const handleRefresh = useCallback(() => {
setIframeVersion((value) => value + 1);
setIframeLoading(true);
refresh();
}, [refresh]);
const effectiveHeight = height ?? 520;
if (isLoading) {
return (
<div className="flex justify-center items-center h-20 bg-gray-50 border border-dashed rounded-md">
<div className="flex h-20 items-center justify-center rounded-md border border-dashed bg-gray-50">
<Loader2 className="h-5 w-5 animate-spin text-gray-400" />
</div>
);
@@ -250,104 +120,83 @@ const CompactTablePreview: React.FC<CompactTablePreviewProps> = ({ tableId, onFu
if (!table) {
return (
<div className="flex items-center justify-center h-20 bg-red-50 border border-red-300 rounded-md text-red-700">
<Table className="h-5 w-5 mr-2" />
<div className="flex h-24 items-center justify-between rounded-md border border-red-200 bg-red-50 px-4 py-2 text-red-600">
<div className="flex items-center gap-2 text-sm">
<TableIcon className="h-5 w-5" />
<span></span>
</div>
<button
type="button"
onClick={handleRefresh}
className="flex items-center gap-2 rounded-md border border-red-200 px-3 py-1 text-xs font-medium"
>
<RotateCw className="h-4 w-4" />
</button>
</div>
);
}
return (
<div
className="relative w-full p-1 border border-gray-200 rounded-md transition-shadow hover:shadow-md"
className="w-full"
onDoubleClick={onFullScreen}
contentEditable={false}
onMouseDown={suppressEditorEvents}
onMouseUp={suppressEditorEvents}
onMouseMove={suppressEditorEvents}
>
<div className="flex justify-between items-center px-2 py-1">
<h3 className="text-sm font-semibold text-gray-700">{table.title}</h3>
<div className="flex items-center space-x-1">
<span className="text-[11px] text-gray-400"></span>
<button
onClick={handleDeleteTable}
className="p-1 text-gray-400 hover:text-red-500 transition-colors"
title="删除表格"
>
<Trash2 className="h-4 w-4" />
</button>
<button
onClick={onFullScreen}
className="p-1 text-gray-400 hover:text-blue-500 transition-colors"
title="进入全屏编辑"
>
<Maximize2 className="h-4 w-4" />
</button>
<div className="relative overflow-hidden rounded-xl border border-gray-200 bg-white shadow-sm transition-shadow hover:shadow-md">
<div className="flex items-center justify-between border-b border-gray-100 bg-white/90 px-4 py-2 backdrop-blur">
<div>
<p className="text-sm font-semibold text-gray-700">{table.title}</p>
<p className="text-xs text-gray-400"> · </p>
</div>
<div className="flex items-center gap-1">
<button
onClick={handleRefresh}
className="rounded-md p-1.5 text-gray-400 ring-offset-white transition-colors hover:text-gray-600 focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-400"
title="刷新嵌入视图"
type="button"
>
<RotateCw className="h-4 w-4" />
</button>
<button
onClick={handleDeleteTable}
className="rounded-md p-1.5 text-gray-400 ring-offset-white transition-colors hover:text-red-500 focus:outline-none focus-visible:ring-2 focus-visible:ring-red-500"
title="删除表格"
type="button"
>
<Trash2 className="h-4 w-4" />
</button>
<button
onClick={onFullScreen}
className="rounded-md p-1.5 text-gray-400 ring-offset-white transition-colors hover:text-blue-500 focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500"
title="进入全屏编辑"
type="button"
>
<Maximize2 className="h-4 w-4" />
</button>
</div>
</div>
</div>
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-100">
<tr>{headerCells}</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{rowsToDisplay.map((row, index) => {
const rowId = (row as { id?: string }).id || index;
return (
<tr
key={rowId}
className="hover:bg-gray-50 relative group cursor-pointer"
onClick={onFullScreen}
>
<td className="w-8 p-0 text-center" />
{columns.map((col) => (
<td
key={col.id}
className="px-3 py-2 whitespace-nowrap text-sm text-gray-900 border-l border-gray-100 text-center align-middle min-w-[80px]"
>
{renderCellContent(col, (row as Record<string, unknown>)[col.id])}
</td>
))}
</tr>
);
})}
{rows.length > rowsToDisplay.length && (
<tr>
<td colSpan={columns.length + 1} className="px-4 py-2 text-center text-xs text-gray-500 italic">
... {rows.length - rowsToDisplay.length}
</td>
</tr>
)}
</tbody>
</table>
</div>
<div className="flex items-center justify-end space-x-4 mt-2 text-xs text-gray-500">
<label className="flex items-center space-x-2">
<span></span>
<input
type="range"
min={5}
max={Math.max(5, totalRowsAvailable)}
value={visibleRows}
onChange={(event) => setVisibleRows(Number(event.target.value))}
className="h-1.5 w-28 accent-blue-500"
<div className="relative w-full bg-gray-50" style={{ height: effectiveHeight, minHeight: 320 }}>
<iframe
key={`${tableId}-${iframeVersion}`}
src={iframeSrc}
title={`online-table-${tableId}`}
className="h-full w-full border-0"
loading="lazy"
onLoad={() => setIframeLoading(false)}
allow="clipboard-read; clipboard-write"
/>
<span>{visibleRows}</span>
</label>
<label className="flex items-center space-x-2">
<span></span>
<input
type="range"
min={5}
max={Math.max(5, totalColsAvailable)}
value={visibleCols}
onChange={(event) => setVisibleCols(Number(event.target.value))}
className="h-1.5 w-28 accent-blue-500"
/>
<span>{visibleCols}</span>
</label>
{iframeLoading && (
<div className="absolute inset-0 flex flex-col items-center justify-center gap-2 bg-white/90">
<Loader2 className="h-5 w-5 animate-spin text-gray-500" />
<span className="text-xs text-gray-500"> Luckysheet ...</span>
</div>
)}
</div>
</div>
</div>
);
@@ -11,6 +11,8 @@ import {
saveOnlineTable,
} from "@/lib/online-table";
import { useDebouncedCallback } from "@/hooks/use-debounced-callback";
import { useLuckysheetLoader } from "@/components/online-table/useLuckysheetLoader";
import { extractRowsForPreview } from "@/components/online-table/utils";
interface FullScreenTableEditorProps {
tableId: string;
@@ -20,68 +22,34 @@ interface FullScreenTableEditorProps {
// Luckysheet 容器的 ID
const LUCKY_SHEET_CONTAINER_ID = "luckysheet-editor-container";
// Luckysheet 资源路径 (相对于 public 目录)
const LUCKY_SHEET_RESOURCES = {
css: [
"/luckysheet/css/luckysheet.css",
"/luckysheet/plugins/plugins.css",
"/luckysheet/plugins/css/pluginsCss.css",
"/luckysheet/assets/iconfont/iconfont.css",
],
js: [
"/luckysheet/plugins/js/plugin.js",
"/luckysheet/luckysheet.umd.js",
],
type LuckysheetSelection =
| {
row?: [number, number];
column?: [number, number];
row_focus?: number;
column_focus?: number;
}
| undefined;
const ENABLE_SINGLE_CLICK_EDIT = true;
const isPrintableKey = (event: KeyboardEvent) => {
if (event.defaultPrevented) return false;
if (event.metaKey || event.ctrlKey || event.altKey) return false;
if (event.key === "Enter" || event.key === "Tab" || event.key === "Escape") return false;
if (event.key.length === 1) return true;
return event.key === "Process" || event.key === "Unidentified";
};
const extractRowsForPreview = (luckysheetData: any, columns: Array<{ id: string }>): TableRowData[] => {
const sheet = Array.isArray(luckysheetData) ? luckysheetData[0] : null;
if (!sheet) return [];
const columnIds = columns.length > 0 ? columns.map((item) => item.id) : Array.from({ length: DEFAULT_TABLE_COLUMNS }, (_, idx) => `col${idx + 1}`);
const rows: TableRowData[] = [];
const grid = Array.isArray(sheet.data) ? sheet.data : [];
const pickValue = (cell: any) => {
if (!cell) return undefined;
if (cell.m !== undefined && cell.m !== null) return cell.m;
if (cell.v?.m !== undefined && cell.v?.m !== null) return cell.v.m;
if (cell.v?.v !== undefined && cell.v?.v !== null) return cell.v.v;
if (cell.v !== undefined && cell.v !== null && typeof cell.v !== "object") return cell.v;
if (cell.w !== undefined && cell.w !== null) return cell.w;
return undefined;
};
grid.forEach((row: any[], rowIndex: number) => {
if (!Array.isArray(row)) return;
const rowObj: TableRowData = {};
let hasValue = false;
columnIds.forEach((colId, colIndex) => {
const value = pickValue(row[colIndex]);
if (value !== undefined && value !== null && value !== "") {
rowObj[colId] = value;
hasValue = true;
}
});
if (hasValue) {
rows.push(rowObj);
}
});
if (rows.length === 0 && Array.isArray(sheet.celldata)) {
const map = new Map<number, TableRowData>();
sheet.celldata.forEach((cell: { r: number; c: number; v: unknown }) => {
const value = pickValue(cell?.v ?? cell);
if (value === undefined || value === null || value === "") return;
const existing = map.get(cell.r) ?? {};
existing[columnIds[cell.c] ?? `col${cell.c + 1}`] = value;
map.set(cell.r, existing);
});
Array.from(map.entries())
.sort(([a], [b]) => a - b)
.forEach(([, row]) => rows.push(row));
const isElementInsideEditorToolbar = (element: HTMLElement | null) => {
if (!element) return false;
if (element.closest(".luckysheet-wa-editor")) {
return true;
}
return rows;
if (element.closest(".luckysheet-modal-dialog")) {
return true;
}
return false;
};
const FullScreenTableEditor: React.FC<FullScreenTableEditorProps> = ({ tableId, onClose }) => {
@@ -89,7 +57,7 @@ const FullScreenTableEditor: React.FC<FullScreenTableEditorProps> = ({ tableId,
const isApplyingSnapshotRef = useRef(false);
const hasInitializedRef = useRef(false);
const lastTableIdRef = useRef<string | null>(null);
const [isLoaded, setIsLoaded] = useState(false);
const isLuckysheetReady = useLuckysheetLoader();
const [tableData, setTableData] = useState<DocumentTable | null>(null);
const [isTableLoading, setIsTableLoading] = useState(true);
const [tableError, setTableError] = useState<string | null>(null);
@@ -98,6 +66,16 @@ const FullScreenTableEditor: React.FC<FullScreenTableEditorProps> = ({ tableId,
const [saveError, setSaveError] = useState<string | null>(null);
const [lastSyncedAt, setLastSyncedAt] = useState<number | null>(null);
const persistSnapshotRef = useRef<((reason: "auto" | "close") => Promise<void>) | null>(null);
const lastPointerDownInGridRef = useRef(false);
useEffect(() => {
if (typeof window !== "undefined") {
(window as unknown as { __wolaiFullScreenState?: { isLoaded: boolean; tableReady: boolean } }).__wolaiFullScreenState = {
isLoaded: isLuckysheetReady,
tableReady: !!tableData,
};
}
}, [isLuckysheetReady, tableData]);
const fetchTable = useCallback((id: string) => {
setIsTableLoading(true);
@@ -127,46 +105,11 @@ const FullScreenTableEditor: React.FC<FullScreenTableEditorProps> = ({ tableId,
lastTableIdRef.current = tableId;
}, [fetchTable, tableId]);
// 动态加载 Luckysheet 资源
useEffect(() => {
if (window.luckysheet) {
setIsLoaded(true);
return;
if (tableData) {
setIsTableLoading(false);
}
const loadResource = (tag: "link" | "script", url: string) => {
if (document.querySelector(`${tag}[href="${url}"]`) || document.querySelector(`${tag}[src="${url}"]`)) {
return true;
}
if (tag === "link") {
const link = document.createElement("link");
link.rel = "stylesheet";
link.href = url;
document.head.appendChild(link);
return true;
} else if (tag === "script") {
return new Promise<void>((resolve) => {
const script = document.createElement("script");
script.src = url;
script.onload = () => resolve();
document.body.appendChild(script);
});
}
return false;
};
LUCKY_SHEET_RESOURCES.css.forEach((url) => loadResource("link", url));
const loadJsSequentially = async () => {
for (const url of LUCKY_SHEET_RESOURCES.js) {
await loadResource("script", url);
}
setIsLoaded(true);
};
loadJsSequentially();
}, []);
}, [tableData]);
const luckysheetSheets = useMemo(() => {
if (tableData?.snapshot?.luckysheet && Array.isArray(tableData.snapshot.luckysheet) && tableData.snapshot.luckysheet.length > 0) {
@@ -176,6 +119,19 @@ const FullScreenTableEditor: React.FC<FullScreenTableEditorProps> = ({ tableId,
return snapshot.luckysheet ?? [];
}, [tableData]);
const normalizedSheets = useMemo(() => {
return luckysheetSheets.map((sheet) => ({
...sheet,
celldata: Array.isArray(sheet.celldata) ? sheet.celldata : [],
config: {
...(sheet.config ?? {}),
rowlen: { ...(sheet.config?.rowlen ?? {}) },
columnlen: { ...(sheet.config?.columnlen ?? {}) },
merge: { ...(sheet.config?.merge ?? {}) },
},
}));
}, [luckysheetSheets]);
const persistSnapshot = useCallback(async (reason: "auto" | "close") => {
if (!tableData || !window.luckysheet || typeof window.luckysheet.getluckysheetfile !== "function") {
return;
@@ -193,12 +149,11 @@ const FullScreenTableEditor: React.FC<FullScreenTableEditorProps> = ({ tableId,
rows,
luckysheet: Array.isArray(luckysheetData) ? luckysheetData : luckysheetSheets,
};
const updated = await saveOnlineTable(tableId, {
await saveOnlineTable(tableId, {
snapshot,
rows,
schema: tableData.schema,
});
setTableData(updated);
setHasPendingChanges(false);
setLastSyncedAt(Date.now());
window.dispatchEvent(new CustomEvent("online-table-saved", { detail: { tableId } }));
@@ -225,9 +180,166 @@ const FullScreenTableEditor: React.FC<FullScreenTableEditorProps> = ({ tableId,
};
}, [debouncedPersist]);
useEffect(() => {
if (!isLuckysheetReady) {
lastPointerDownInGridRef.current = false;
return;
}
const container = document.getElementById(LUCKY_SHEET_CONTAINER_ID);
if (!container) {
return;
}
const handlePointerDownInside = () => {
lastPointerDownInGridRef.current = true;
};
const handlePointerDownDocument = (event: PointerEvent) => {
if (!(event.target instanceof Node)) {
return;
}
if (!container.contains(event.target)) {
lastPointerDownInGridRef.current = false;
}
};
container.addEventListener("pointerdown", handlePointerDownInside);
document.addEventListener("pointerdown", handlePointerDownDocument);
return () => {
container.removeEventListener("pointerdown", handlePointerDownInside);
document.removeEventListener("pointerdown", handlePointerDownDocument);
};
}, [isLuckysheetReady]);
const focusLuckysheetEditor = useCallback(() => {
requestAnimationFrame(() => {
const editor = document.getElementById("luckysheet-rich-text-editor");
if (editor && typeof editor.focus === "function") {
editor.focus();
}
});
}, []);
const shouldAutoFocusEditor = useCallback(
(target: EventTarget | null) => {
if (!isLuckysheetReady || !tableData) {
return false;
}
const container = document.getElementById(LUCKY_SHEET_CONTAINER_ID);
if (!container) {
return false;
}
const editor = document.getElementById("luckysheet-rich-text-editor");
if (!editor || document.activeElement === editor) {
return false;
}
const targetElement = target instanceof HTMLElement ? target : null;
if (isElementInsideEditorToolbar(targetElement)) {
return false;
}
const activeElement = document.activeElement as HTMLElement | null;
if (isElementInsideEditorToolbar(activeElement)) {
return false;
}
if (targetElement && container.contains(targetElement)) {
return true;
}
if (activeElement && container.contains(activeElement)) {
return true;
}
return lastPointerDownInGridRef.current;
},
[isLuckysheetReady, tableData],
);
const ensureInlineEditor = useCallback(
(target: EventTarget | null) => {
if (!shouldAutoFocusEditor(target)) {
return false;
}
if (isApplyingSnapshotRef.current) {
return false;
}
if (!window.luckysheet || typeof window.luckysheet.enterEditMode !== "function") {
return false;
}
window.luckysheet.enterEditMode();
focusLuckysheetEditor();
return true;
},
[focusLuckysheetEditor, shouldAutoFocusEditor],
);
useEffect(() => {
if (!isLuckysheetReady) {
return;
}
const handleKeydown = (event: KeyboardEvent) => {
if (!isPrintableKey(event)) {
return;
}
ensureInlineEditor(event.target);
};
const handleCompositionStart = (event: CompositionEvent) => {
ensureInlineEditor(event.target);
};
window.addEventListener("keydown", handleKeydown, true);
window.addEventListener("compositionstart", handleCompositionStart, true);
return () => {
window.removeEventListener("keydown", handleKeydown, true);
window.removeEventListener("compositionstart", handleCompositionStart, true);
};
}, [ensureInlineEditor, isLuckysheetReady]);
const isSingleCellSelection = useCallback((range: LuckysheetSelection[] | undefined) => {
if (!Array.isArray(range) || range.length !== 1) {
return false;
}
const target = range[0];
if (!target) {
return false;
}
const rowRange = target.row ?? (typeof target.row_focus === "number" ? [target.row_focus, target.row_focus] : undefined);
const columnRange = target.column ?? (typeof target.column_focus === "number" ? [target.column_focus, target.column_focus] : undefined);
if (!rowRange || !columnRange) {
return false;
}
return rowRange[0] === rowRange[1] && columnRange[0] === columnRange[1];
}, []);
const tryEnterSingleClickEdit = useCallback(
(range: LuckysheetSelection[] | undefined) => {
if (!ENABLE_SINGLE_CLICK_EDIT) {
return;
}
if (isApplyingSnapshotRef.current) {
return;
}
if (typeof window === "undefined") {
return;
}
const luckysheetInstance = window.luckysheet;
if (!luckysheetInstance || typeof luckysheetInstance.enterEditMode !== "function") {
return;
}
if (!isSingleCellSelection(range)) {
return;
}
setTimeout(() => {
const editor = document.getElementById("luckysheet-rich-text-editor");
if (editor && document.activeElement === editor) {
return;
}
luckysheetInstance.enterEditMode();
focusLuckysheetEditor();
}, 0);
},
[focusLuckysheetEditor, isSingleCellSelection],
);
// Luckysheet 初始化和清理
useEffect(() => {
if (!isLoaded || !tableData || !containerRef.current || !window.luckysheet) {
if (!isLuckysheetReady || !tableData || !containerRef.current || !window.luckysheet) {
return;
}
@@ -243,6 +355,8 @@ const FullScreenTableEditor: React.FC<FullScreenTableEditorProps> = ({ tableId,
containerRef.current.innerHTML = "";
}
const gridKey = tableData?.grid_key ?? tableId;
const loadUrl = gridKey ? `/api/luckysheet/load?gridKey=${gridKey}` : "";
const options = {
container: LUCKY_SHEET_CONTAINER_ID,
title: tableData.title ?? tableId,
@@ -254,17 +368,64 @@ const FullScreenTableEditor: React.FC<FullScreenTableEditorProps> = ({ tableId,
allowEdit: true,
row: DEFAULT_TABLE_ROWS,
column: DEFAULT_TABLE_COLUMNS,
data: luckysheetSheets,
data: normalizedSheets,
allowUpdate: false,
gridKey,
loadUrl,
uploadImage: async (file: File) => {
const formData = new FormData();
formData.append("image", file);
const response = await fetch("/api/luckysheet/upload-image", {
method: "POST",
body: formData,
});
const payload = await response.json().catch(() => null);
if (!response.ok || !payload?.url) {
throw new Error(payload?.msg ?? "上传图片失败");
}
return payload.url as string;
},
imageUrlHandle: (url: string) => url,
hook: {
workbookCreateAfter: () => {
setIsTableLoading(false);
},
updated: () => {
if (isApplyingSnapshotRef.current) return;
setHasPendingChanges(true);
debouncedPersist();
},
cellEditBefore: () => {
focusLuckysheetEditor();
},
rangeSelect: (_sheet: unknown, selectedRange: LuckysheetSelection[] | LuckysheetSelection | undefined) => {
const normalizedRange = Array.isArray(selectedRange)
? (selectedRange as LuckysheetSelection[])
: selectedRange
? [selectedRange]
: undefined;
focusLuckysheetEditor();
tryEnterSingleClickEdit(normalizedRange);
},
rangeMoveAfter: (_oldRange: LuckysheetSelection[] | undefined, newRange: LuckysheetSelection[] | undefined) => {
focusLuckysheetEditor();
tryEnterSingleClickEdit(newRange);
},
},
};
window.luckysheet.create(options);
if (process.env.NODE_ENV !== "production") {
// eslint-disable-next-line no-console
console.log("[FullScreenTableEditor] init luckysheet", { tableId, sheets: options.data });
}
try {
window.luckysheet.create(options);
} catch (error) {
console.error("Luckysheet 初始化失败", error);
setTableError("Luckysheet 初始化失败,请重试");
isApplyingSnapshotRef.current = false;
return;
}
// 等待首帧渲染完成再开放 updated 事件
setTimeout(() => {
@@ -275,16 +436,20 @@ const FullScreenTableEditor: React.FC<FullScreenTableEditorProps> = ({ tableId,
if (window.luckysheet) {
window.luckysheet.destroy(LUCKY_SHEET_CONTAINER_ID);
}
if (containerRef.current) {
containerRef.current.innerHTML = "";
}
hasInitializedRef.current = false;
};
}, [debouncedPersist, luckysheetSheets, isLoaded, tableData, tableId]);
}, [debouncedPersist, normalizedSheets, isLuckysheetReady, tableData, tableId, focusLuckysheetEditor, tryEnterSingleClickEdit]);
const handleClose = async () => {
await persistSnapshot("close");
onClose();
};
const showLoadingOverlay = !isLoaded || isTableLoading;
const loadingMessage = !isLoaded ? "正在加载 Luckysheet 资源..." : "正在加载表格数据...";
const showLoadingOverlay = !isLuckysheetReady || isTableLoading;
const loadingMessage = !isLuckysheetReady ? "正在加载 Luckysheet 资源..." : "正在加载表格数据...";
const statusText = saveError
? saveError
@@ -325,7 +490,7 @@ const FullScreenTableEditor: React.FC<FullScreenTableEditorProps> = ({ tableId,
id={LUCKY_SHEET_CONTAINER_ID}
ref={containerRef}
className="flex-grow w-full h-full"
style={{ display: isLoaded && !isTableLoading && !tableError ? "block" : "none" }}
style={{ display: isLuckysheetReady && !isTableLoading && !tableError ? "block" : "none" }}
/>
{showLoadingOverlay && (
@@ -0,0 +1,217 @@
"use client";
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Loader2, RotateCw } from "lucide-react";
import type { DocumentTable } from "@/types/online-table";
import {
DEFAULT_TABLE_COLUMNS,
DEFAULT_TABLE_ROWS,
DEFAULT_TABLE_SCHEMA,
createDefaultTableSnapshot,
getDocumentTable,
saveOnlineTable,
} from "@/lib/online-table";
import { useLuckysheetLoader } from "@/components/online-table/useLuckysheetLoader";
import { useDebouncedCallback } from "@/hooks/use-debounced-callback";
import { extractRowsForPreview } from "@/components/online-table/utils";
interface HeadlessTableViewerProps {
tableId: string;
embed?: boolean;
}
const VIEWER_CONTAINER_PREFIX = "headless-table-viewer-";
const HeadlessTableViewer: React.FC<HeadlessTableViewerProps> = ({ tableId, embed = false }) => {
const containerId = useMemo(() => `${VIEWER_CONTAINER_PREFIX}${tableId}`, [tableId]);
const containerRef = useRef<HTMLDivElement>(null);
const isLuckysheetReady = useLuckysheetLoader();
const [table, setTable] = useState<DocumentTable | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [reloadVersion, setReloadVersion] = useState(0);
const [isSaving, setIsSaving] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
useEffect(() => {
let canceled = false;
setIsLoading(true);
setError(null);
getDocumentTable(tableId)
.then((data) => {
if (!canceled) {
setTable(data);
}
})
.catch((err) => {
console.error("加载表格失败", err);
if (!canceled) {
setTable(null);
setError("无法加载表格数据");
}
})
.finally(() => {
if (!canceled) {
setIsLoading(false);
}
});
return () => {
canceled = true;
};
}, [tableId, reloadVersion]);
const persistSnapshot = useCallback(async () => {
if (
!embed ||
!table ||
!window.luckysheet ||
typeof window.luckysheet.getluckysheetfile !== "function"
) {
return;
}
setIsSaving(true);
setSaveError(null);
try {
const luckysheetData = window.luckysheet.getluckysheetfile?.() ?? [];
const rows = extractRowsForPreview(
luckysheetData,
(table.schema?.columns ?? []).map((item) => ({ id: item.id })),
).filter((row) => row && typeof row === "object" && Object.keys(row).length > 0);
const snapshot = {
...(table.snapshot ?? {}),
rows,
luckysheet: Array.isArray(luckysheetData) ? luckysheetData : table.snapshot?.luckysheet ?? [],
};
await saveOnlineTable(tableId, {
snapshot,
rows,
schema: table.schema,
});
setTable((prev) => (prev ? { ...prev, snapshot, rows } : prev));
window.dispatchEvent(new CustomEvent("online-table-saved", { detail: { tableId } }));
} catch (err) {
console.error("内嵌表格保存失败", err);
setSaveError("自动保存失败");
} finally {
setIsSaving(false);
}
}, [embed, table, tableId]);
const debouncedPersist = useDebouncedCallback(() => {
void persistSnapshot();
}, 1200);
useEffect(() => {
return () => {
debouncedPersist.cancel();
};
}, [debouncedPersist]);
useEffect(() => {
if (!isLuckysheetReady || !table || !containerRef.current || !window.luckysheet) {
return;
}
if (typeof window.luckysheet.destroy === "function") {
window.luckysheet.destroy(containerId);
}
containerRef.current.innerHTML = "";
const sheets =
(table.snapshot?.luckysheet && Array.isArray(table.snapshot.luckysheet) && table.snapshot.luckysheet.length > 0)
? table.snapshot.luckysheet
: (createDefaultTableSnapshot(table.schema ?? DEFAULT_TABLE_SCHEMA).luckysheet ?? []);
window.luckysheet.create({
container: containerId,
title: table.title ?? tableId,
lang: "zh",
showinfobar: false,
showtoolbar: false,
showsheetbar: sheets.length > 1,
showstatisticBar: false,
allowEdit: embed,
allowUpdate: false,
enableAddBackTop: false,
enableAddRow: false,
row: DEFAULT_TABLE_ROWS,
column: DEFAULT_TABLE_COLUMNS,
data: sheets,
pointEdit: embed,
pointEditZoom: window.devicePixelRatio ?? 1,
pointEditUpdate: embed
? () => {
debouncedPersist();
}
: undefined,
hook: embed
? {
updated: () => {
debouncedPersist();
},
}
: undefined,
});
return () => {
if (window.luckysheet && typeof window.luckysheet.destroy === "function") {
window.luckysheet.destroy(containerId);
}
};
}, [containerId, isLuckysheetReady, table, tableId]);
const overlayVisible = isLoading || !isLuckysheetReady;
const overlayText = !isLuckysheetReady ? "正在加载 Luckysheet 资源..." : "正在加载表格数据...";
const embedContainerStyle = embed ? { minHeight: "100vh", height: "100vh" } : undefined;
return (
<div
className={embed ? "w-full bg-transparent" : "min-h-screen w-full bg-white"}
style={embedContainerStyle}
>
<div
className={embed ? "relative w-full" : "relative h-[calc(100vh-64px)] w-full"}
style={embedContainerStyle}
>
<div
id={containerId}
ref={containerRef}
className="h-full w-full"
style={{ display: isLuckysheetReady && !!table && !error ? "block" : "none" }}
/>
{overlayVisible && (
<div className="absolute inset-0 flex flex-col items-center justify-center gap-3 bg-white/90">
<Loader2 className="h-6 w-6 animate-spin text-gray-500" />
<span className="text-sm text-gray-500">{overlayText}</span>
</div>
)}
{error && !overlayVisible && (
<div className="absolute inset-0 flex flex-col items-center justify-center gap-3 bg-white/95 text-red-500">
<span className="text-sm">{error}</span>
<button
type="button"
className="flex items-center gap-2 rounded-md border border-red-300 px-3 py-1 text-sm"
onClick={() => setReloadVersion((value) => value + 1)}
>
<RotateCw className="h-4 w-4" />
</button>
</div>
)}
{embed && (isSaving || saveError) && (
<div className="pointer-events-none absolute bottom-2 right-3 flex flex-col items-end text-xs">
{isSaving && <span className="rounded-md bg-white/80 px-2 py-0.5 text-gray-500 shadow">...</span>}
{saveError && <span className="mt-1 rounded-md bg-white/80 px-2 py-0.5 text-red-500 shadow">{saveError}</span>}
</div>
)}
</div>
</div>
);
};
export default HeadlessTableViewer;
@@ -0,0 +1,128 @@
"use client";
import { useEffect, useState } from "react";
const LUCKYSHEET_VERSION = "crdt-20251127";
const withVersion = (path: string) => `${path}?v=${LUCKYSHEET_VERSION}`;
export const LUCKYSHEET_RESOURCES = {
css: [
withVersion("/luckysheet/css/luckysheet.css"),
withVersion("/luckysheet/plugins/plugins.css"),
withVersion("/luckysheet/plugins/css/pluginsCss.css"),
withVersion("/luckysheet/assets/iconfont/iconfont.css"),
],
js: [
withVersion("/luckysheet/plugins/js/plugin.js"),
withVersion("/luckysheet/luckysheet.umd.js"),
],
};
let loaderPromise: Promise<void> | null = null;
const appendCssOnce = (href: string) => {
if (typeof document === "undefined") {
return;
}
const marker = `link[data-luckysheet-href="${href}"]`;
if (document.querySelector(marker)) {
return;
}
const link = document.createElement("link");
link.rel = "stylesheet";
link.href = href;
link.dataset.luckysheetHref = href;
document.head.appendChild(link);
};
const appendScriptOnce = (src: string) =>
new Promise<void>((resolve, reject) => {
if (typeof document === "undefined") {
resolve();
return;
}
const marker = `script[data-luckysheet-src="${src}"]`;
const existing = document.querySelector<HTMLScriptElement>(marker);
if (existing) {
if (existing.dataset.loaded === "1") {
resolve();
return;
}
const handleLoad = () => {
existing.dataset.loaded = "1";
resolve();
};
const handleError = () => {
reject(new Error(`加载 Luckysheet 资源失败: ${src}`));
};
existing.addEventListener("load", handleLoad, { once: true });
existing.addEventListener("error", handleError, { once: true });
return;
}
const script = document.createElement("script");
script.src = src;
script.async = true;
script.dataset.luckysheetSrc = src;
script.onload = () => {
script.dataset.loaded = "1";
resolve();
};
script.onerror = () => {
reject(new Error(`加载 Luckysheet 资源失败: ${src}`));
};
document.body.appendChild(script);
});
export const ensureLuckysheetLoaded = async () => {
if (typeof window === "undefined") {
return;
}
if (window.luckysheet) {
return;
}
if (loaderPromise) {
await loaderPromise;
return;
}
loaderPromise = (async () => {
LUCKYSHEET_RESOURCES.css.forEach((href) => appendCssOnce(href));
for (const src of LUCKYSHEET_RESOURCES.js) {
await appendScriptOnce(src);
}
})();
await loaderPromise;
};
export const useLuckysheetLoader = () => {
const [isReady, setIsReady] = useState(
typeof window !== "undefined" && typeof window.luckysheet !== "undefined",
);
useEffect(() => {
let canceled = false;
if (typeof window === "undefined") {
return;
}
if (window.luckysheet) {
setIsReady(true);
return;
}
ensureLuckysheetLoaded()
.then(() => {
if (!canceled) {
setIsReady(true);
}
})
.catch((error) => {
console.error("Luckysheet 资源加载失败", error);
});
return () => {
canceled = true;
};
}, []);
return isReady;
};
@@ -0,0 +1,61 @@
import type { TableRowData } from "@/types/online-table";
import { DEFAULT_TABLE_COLUMNS } from "@/lib/online-table";
const pickCellValue = (cell: any) => {
if (!cell) return undefined;
if (cell.m != null) return cell.m;
if (cell.v?.m != null) return cell.v.m;
if (cell.v?.v != null) return cell.v.v;
if (cell.v != null && typeof cell.v !== "object") return cell.v;
if (cell.w != null) return cell.w;
return undefined;
};
export const extractRowsForPreview = (
luckysheetData: any,
columns: Array<{ id: string }>,
): TableRowData[] => {
const sheet = Array.isArray(luckysheetData) ? luckysheetData[0] : null;
if (!sheet) {
return [];
}
const columnIds =
columns.length > 0 ? columns.map((item) => item.id) : Array.from({ length: DEFAULT_TABLE_COLUMNS }, (_, idx) => `col${idx + 1}`);
const rows: TableRowData[] = [];
const grid = Array.isArray(sheet.data) ? sheet.data : [];
grid.forEach((row: any[], rowIndex: number) => {
if (!Array.isArray(row)) return;
const rowObj: TableRowData = {};
let hasValue = false;
columnIds.forEach((colId, colIndex) => {
const value = pickCellValue(row[colIndex]);
if (value !== undefined && value !== null && value !== "") {
rowObj[colId] = value;
hasValue = true;
}
});
if (hasValue) {
rows.push(rowObj);
}
});
if (rows.length === 0 && Array.isArray(sheet.celldata)) {
const map = new Map<number, TableRowData>();
sheet.celldata.forEach((cell: { r: number; c: number; v: unknown }) => {
const value = pickCellValue(cell?.v ?? cell);
if (value === undefined || value === null || value === "") return;
const existing = map.get(cell.r) ?? {};
existing[columnIds[cell.c] ?? `col${cell.c + 1}`] = value;
map.set(cell.r, existing);
});
Array.from(map.entries())
.sort(([a], [b]) => a - b)
.forEach(([, row]) => rows.push(row));
}
return rows;
};
@@ -1,5 +1,6 @@
"use client";
import { useEffect } from "react";
import type { Session } from "@supabase/supabase-js";
import { SessionContextProvider } from "@supabase/auth-helpers-react";
import { supabaseBrowser } from "@/lib/supabase/client";
@@ -10,6 +11,34 @@ interface SupabaseProviderProps {
}
export function SupabaseProvider({ session, children }: SupabaseProviderProps) {
useEffect(() => {
const {
data: { subscription },
} = supabaseBrowser.auth.onAuthStateChange((_event, newSession) => {
fetch("/api/auth/callback", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ event: _event, session: newSession }),
});
});
supabaseBrowser.auth.getSession().then(({ data }) => {
if (data.session) {
fetch("/api/auth/callback", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ event: "INITIAL_SESSION", session: data.session }),
});
}
});
return () => {
subscription.unsubscribe();
};
}, []);
return (
<SessionContextProvider supabaseClient={supabaseBrowser} initialSession={session}>
{children}
+33 -25
View File
@@ -10,32 +10,40 @@ export const DEFAULT_TABLE_SCHEMA: TableSchema = {
export const DEFAULT_TABLE_ROWS = 50;
export const DEFAULT_TABLE_COLUMNS = 15;
export const createDefaultTableSnapshot = (schema: TableSchema): DocumentTableSnapshot => ({
rows: [],
luckysheet: [
{
name: "Sheet1",
index: 0,
status: 1,
order: 0,
hide: 0,
row: DEFAULT_TABLE_ROWS,
column: DEFAULT_TABLE_COLUMNS,
defaultRowHeight: 19,
defaultColWidth: 73,
celldata: [],
config: {
columnlen: {},
rowlen: {},
export const createDefaultTableSnapshot = (schema: TableSchema): DocumentTableSnapshot => {
const frozenRowCount = schema.frozenRowCount ?? 0;
const frozenColCount = schema.frozenColCount ?? 0;
return {
rows: [],
luckysheet: [
{
name: "Sheet1",
index: 0,
status: 1,
order: 0,
hide: 0,
row: DEFAULT_TABLE_ROWS,
column: DEFAULT_TABLE_COLUMNS,
defaultRowHeight: 19,
defaultColWidth: 73,
celldata: [],
config: {
columnlen: {},
rowlen: {},
},
frozen: {
type: frozenRowCount > 0 || frozenColCount > 0 ? "both" : undefined,
row_focus: frozenRowCount,
column_focus: frozenColCount,
},
scrollLeft: 0,
scrollTop: 0,
zoomRatio: 1,
showGridLines: true,
},
frozen: {},
scrollLeft: 0,
scrollTop: 0,
zoomRatio: 1,
showGridLines: true,
},
],
});
],
};
};
/**
* Supabase 线
+1
View File
@@ -35,6 +35,7 @@ export interface DocumentTableSnapshot {
export interface DocumentTable {
id: string;
document_id: string;
grid_key: string;
title: string;
schema: TableSchema;
snapshot?: DocumentTableSnapshot | null;