fix: 统一系统化待办跨设备布局 #695

Merged
yueyang merged 1 commits from fix/yueyang-systemization-cross-device-layout into main 2026-10-27 17:31:07 +08:00
6 changed files with 239 additions and 23 deletions

View File

@ -29,6 +29,20 @@ class SystemizationTodos extends Controller
'none' => '无提醒',
];
private const OWNER_OPTIONS = ['人参', '晨曦', '常仪', '待定'];
private const COLUMN_WIDTHS_SETTING = 'business.systemization_todos.column_widths';
private const COLUMN_WIDTH_KEYS = [
'type',
'section',
'sequence',
'title',
'owner',
'assignee',
'start_date',
'due_date',
'reminder',
'status',
'progress_notes',
];
private const OWNER_ALIASES = [
'人参' => '人参',
'苏木' => '人参',
@ -205,6 +219,7 @@ class SystemizationTodos extends Controller
$filters,
JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES,
)),
'todoColumnWidths' => $this->columnWidths(),
'todoSummary' => [
'total' => $totalCount,
'incomplete' => $incompleteCount,
@ -224,10 +239,57 @@ class SystemizationTodos extends Controller
'import' => route('business.systemization-todos.import'),
'history' => route('business.systemization-todos.history'),
'historyExport' => route('business.systemization-todos.history.export'),
'columnWidths' => route('business.systemization-todos.column-widths'),
],
];
}
public function saveColumnWidths(Request $request): JsonResponse
{
$ruleKeys = implode(',', self::COLUMN_WIDTH_KEYS);
$data = $request->validate([
'widths' => ['required', 'array:' . $ruleKeys],
'widths.*' => ['required', 'integer', 'min:64', 'max:720'],
]);
$widths = [];
foreach (self::COLUMN_WIDTH_KEYS as $key) {
$widths[$key] = (int) $data['widths'][$key];
}
setting([
self::COLUMN_WIDTHS_SETTING => json_encode(
$widths,
JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES,
),
])->save();
return response()->json(['ok' => true, 'widths' => $widths]);
}
private function columnWidths(): ?array
{
$encoded = setting(self::COLUMN_WIDTHS_SETTING);
$widths = is_string($encoded) ? json_decode($encoded, true) : $encoded;
if (! is_array($widths)) {
return null;
}
$normalized = [];
foreach (self::COLUMN_WIDTH_KEYS as $key) {
$width = $widths[$key] ?? null;
if (! is_int($width) && ! ctype_digit((string) $width)) {
return null;
}
$width = (int) $width;
if ($width < 64 || $width > 720) {
return null;
}
$normalized[$key] = $width;
}
return $normalized;
}
private function hierarchySequenceMap(): array
{
$todos = SystemizationTodo::query()

Binary file not shown.

View File

@ -41,6 +41,14 @@
font-weight: 700;
}
@font-face {
font-family: "CaiWu CJK";
src: url("/public/fonts/caiwu-ui/DroidSansFallback.ttf") format("truetype");
font-display: swap;
font-style: normal;
font-weight: 400;
}
@tailwind base;
@tailwind components;
@tailwind utilities;
@ -1063,7 +1071,7 @@ form {
body {
@apply text-black;
font-family: "JetBrains Mono", "Microsoft YaHei", "PingFang SC", monospace;
font-family: "JetBrains Mono", "CaiWu CJK", monospace;
height: 100%;
}

View File

@ -19,11 +19,27 @@
.sys-todo-summary__value { color:#171717; font-size:1rem; font-weight:700; font-variant-numeric:tabular-nums; }
.sys-todo-summary__item.is-warning .sys-todo-summary__value { color:#935f07; }
.sys-todo-summary__item.is-success .sys-todo-summary__value { color:#53793e; }
.sys-todo-filter { display:flex; flex-wrap:wrap; gap:.65rem .55rem; align-items:end; margin:0 0 .85rem; padding:.7rem; border:1px solid #e6dfd2; border-radius:.65rem; background:#fcfaf5; }
.sys-todo-filter label { display:grid; flex:1 1 7.4rem; gap:.25rem; min-width:0; color:#595959; font-size:.72rem; }
.sys-todo-filter label.sys-todo-filter__keyword { flex:2.2 1 16rem; }
.sys-todo-filter { display:grid; grid-template-columns:repeat(6,minmax(8.5rem,1fr)); gap:.65rem .55rem; align-items:end; margin:0 0 .85rem; padding:.7rem; border:1px solid #e6dfd2; border-radius:.65rem; background:#fcfaf5; }
.sys-todo-filter > label { display:grid; grid-column:span 1; gap:.25rem; min-width:0; color:#595959; font-size:.72rem; }
.sys-todo-filter > label.sys-todo-filter__keyword { grid-column:span 3; }
.sys-todo-filter input, .sys-todo-filter select { width:100%; height:2.125rem; padding:0 .6rem; border:1px solid var(--sys-border); border-radius:.45rem; background:#fff; color:#171717; font:inherit; font-size:.8125rem; }
.sys-todo-filter__multi { display:grid; flex:1 1 8.2rem; gap:.25rem; min-width:0; color:#595959; font-size:.72rem; }
.sys-todo-filter > label input,
.sys-todo-filter > label select { -webkit-appearance:none; appearance:none; }
.sys-todo-page select:not([multiple]) {
padding-right:2rem;
background-color:#fff;
background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 8'%3E%3Cpath d='M1 1.5 6 6.5l5-5' fill='none' stroke='%23856632' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
background-repeat:no-repeat;
background-position:right .7rem center;
background-size:.7rem .47rem;
}
.sys-todo-page select:not([multiple])::-ms-expand { display:none; }
.sys-todo-page select:focus-visible,
.sys-todo-filter input:focus-visible,
.sys-todo-field input:focus-visible,
.sys-todo-field textarea:focus-visible { border-color:var(--sys-gold); outline:none; box-shadow:0 0 0 3px rgba(133,102,50,.18); }
.sys-todo-page input[type="search"]::-webkit-search-cancel-button { -webkit-appearance:none; display:none; }
.sys-todo-filter__multi { display:grid; grid-column:span 1; gap:.25rem; min-width:0; color:#595959; font-size:.72rem; }
.sys-todo-filter__multi details { position:relative; min-width:0; }
.sys-todo-filter__multi > details > summary { position:relative; display:flex; align-items:center; min-height:2.125rem; padding:0 1.8rem 0 .6rem; border:1px solid var(--sys-border); border-radius:.45rem; background:#fff; color:#171717; cursor:pointer; list-style:none; }
.sys-todo-filter__multi > details > summary::-webkit-details-marker { display:none; }
@ -37,7 +53,7 @@
.sys-todo-filter__multi-menu label:hover { background:var(--sys-gold-soft); }
.sys-todo-filter__multi-menu input[type="checkbox"] { flex:0 0 auto; width:1rem; height:1rem; padding:0; accent-color:var(--sys-gold); }
.sys-todo-filter__multi-empty { padding:.65rem; color:#777; font-size:.75rem; text-align:center; }
.sys-todo-filter__actions { display:flex; flex:0 0 auto; gap:.45rem; }
.sys-todo-filter__actions { display:flex; grid-column:span 2; justify-content:flex-end; gap:.45rem; }
.sys-todo-table-wrap { overflow-x:auto; overflow-y:visible; border:1px solid var(--sys-border); border-radius:.65rem; background:#fff; scrollbar-width:none; }
.sys-todo-table-wrap::-webkit-scrollbar { height:0; }
.sys-todo-table { width:108rem; min-width:108rem; table-layout:fixed; border-collapse:separate; border-spacing:0; font-size:.75rem; }
@ -152,6 +168,31 @@
.sys-todo-owner-checks label { display:flex; align-items:center; gap:.35rem; min-height:1.6rem; color:#333; font-size:.76rem; cursor:pointer; }
.sys-todo-owner-checks label:hover { color:var(--sys-gold); }
.sys-todo-owner-checks input[type="checkbox"] { flex:0 0 auto; width:1rem; height:1rem; min-height:0; padding:0; accent-color:var(--sys-gold); }
.sys-todo-page input[type="checkbox"] {
-webkit-appearance:none;
appearance:none;
display:inline-grid;
flex:0 0 1rem;
place-items:center;
width:1rem;
height:1rem;
min-height:0;
padding:0;
border:1px solid #bfb3a0;
border-radius:.24rem;
background-color:#fff;
background-image:none;
cursor:pointer;
}
.sys-todo-page input[type="checkbox"]:checked {
border-color:var(--sys-gold);
background-color:var(--sys-gold);
background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12'%3E%3Cpath d='M2.2 6.4 4.8 9 9.8 3.3' fill='none' stroke='%23FFFFFF' stroke-width='1.8' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
background-repeat:no-repeat;
background-position:center;
background-size:.72rem;
}
.sys-todo-page input[type="checkbox"]:focus-visible { outline:2px solid var(--sys-gold); outline-offset:2px; }
.sys-todo-upload-zone { display:flex; flex-wrap:wrap; align-items:center; gap:.45rem .65rem; min-height:4.5rem; padding:.7rem; border:1px dashed #cfc3ad; border-radius:.55rem; background:#fcfaf5; }
.sys-todo-upload-zone input[type="file"] { position:absolute; width:1px; height:1px; padding:0; overflow:hidden; clip:rect(0,0,0,0); white-space:nowrap; border:0; }
.sys-todo-upload-button { display:inline-flex; align-items:center; justify-content:center; min-height:2rem; padding:0 .75rem; border:1px solid var(--sys-gold); border-radius:.4rem; background:var(--sys-gold); color:#fff; font-size:.78rem; cursor:pointer; }
@ -239,9 +280,22 @@
.sys-todo-image-dialog__body button[hidden] { visibility:hidden; }
.sys-todo-image-dialog__foot { display:flex; justify-content:flex-end; padding:.55rem .85rem; background:#1d1d1f; }
.sys-todo-image-dialog__foot a { color:#f0e4bf; font-size:.75rem; }
@media (max-width:1300px) { .sys-todo-summary { grid-template-columns:repeat(2,minmax(0,1fr)); } }
@media (max-width:900px) { .sys-todo-filter label, .sys-todo-filter label.sys-todo-filter__keyword { flex-basis:9rem; } .sys-todo-filter__actions { width:100%; } .sys-todo-filter__actions .sys-todo-page__button { flex:1; } }
@media (max-width:720px) { .sys-todo-summary { grid-template-columns:repeat(2,minmax(0,1fr)); } .sys-todo-filter label, .sys-todo-filter label.sys-todo-filter__keyword { flex-basis:10rem; } .sys-todo-field--2,.sys-todo-field--3,.sys-todo-field--4,.sys-todo-field--6 { grid-column:span 12; } .sys-todo-combined-grid { grid-column:span 12; grid-template-columns:1fr; } }
@media (max-width:1100px) {
.sys-todo-summary { grid-template-columns:repeat(2,minmax(0,1fr)); }
.sys-todo-filter { grid-template-columns:repeat(3,minmax(8.5rem,1fr)); }
.sys-todo-filter > label.sys-todo-filter__keyword { grid-column:span 2; }
.sys-todo-filter__actions { grid-column:span 3; }
}
@media (max-width:720px) {
.sys-todo-filter { grid-template-columns:minmax(0,1fr); }
.sys-todo-filter > label,
.sys-todo-filter > label.sys-todo-filter__keyword,
.sys-todo-filter__multi,
.sys-todo-filter__actions { grid-column:1 / -1; }
.sys-todo-filter__actions .sys-todo-page__button { flex:1; }
.sys-todo-field--2,.sys-todo-field--3,.sys-todo-field--4,.sys-todo-field--6 { grid-column:span 12; }
.sys-todo-combined-grid { grid-column:span 12; grid-template-columns:1fr; }
}
</style>
<div class="biz-card-head">
@ -961,6 +1015,8 @@
var routes = @json($todoRoutes, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
var positionGroups = @json($todoPositionGroups, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
var contentOptions = @json($todoContentOptions, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
var serverColumnWidths = @json($todoColumnWidths, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
var csrfToken = @json(csrf_token());
var currentTodoId = null;
var currentGroupTodoId = null;
var currentTodoTitle = '';
@ -979,7 +1035,8 @@
var scrollRail = document.querySelector('[data-sys-todo-scrollrail]');
var scrollRailSpacer = document.querySelector('[data-sys-todo-scrollrail-spacer]');
var scrollRailFrame = null;
var columnStorageKey = 'caiwu-systemization-todo-column-widths-v1';
var columnStorageKey = 'caiwu-systemization-todo-column-widths-v2';
var columnWidthSaveTimer = null;
var defaultColumnWidths = todoColumns.map(function (column) {
return Number(column.getAttribute('data-default-width') || 100);
});
@ -1137,31 +1194,72 @@
todoTable.style.minWidth = total + 'px';
todoTable.style.setProperty('--sys-todo-type-width', columnWidths[0] + 'px');
if (persist) {
try {
window.localStorage.setItem(columnStorageKey, JSON.stringify(columnWidths));
} catch (error) {
// Column resizing still works when browser storage is unavailable.
}
persistColumnWidths(columnWidths);
}
scheduleScrollRailSync();
}
function columnWidthsFromPreference(preference) {
if (!preference) return null;
var values = Array.isArray(preference)
? preference
: todoColumns.map(function (column) {
return preference[column.getAttribute('data-sys-todo-col')];
});
if (values.length !== todoColumns.length
|| values.some(function (value) { return value === null || value === undefined || value === ''; })) {
return null;
}
return values.map(Number);
}
function persistColumnWidths(widths) {
try {
window.localStorage.setItem(columnStorageKey, JSON.stringify(widths));
} catch (error) {
// The shared server preference remains available when local storage is blocked.
}
if (columnWidthSaveTimer !== null) {
window.clearTimeout(columnWidthSaveTimer);
}
columnWidthSaveTimer = window.setTimeout(function () {
columnWidthSaveTimer = null;
var payload = {};
todoColumns.forEach(function (column, index) {
payload[column.getAttribute('data-sys-todo-col')] = widths[index];
});
window.fetch(routes.columnWidths, {
method: 'POST',
credentials: 'same-origin',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
'X-CSRF-TOKEN': csrfToken
},
body: JSON.stringify({ widths: payload })
}).catch(function () {
// Keep the current browser usable; the next resize retries the shared save.
});
}, 250);
}
function resetColumnWidth(index) {
var widths = columnWidths.slice();
widths[index] = defaultColumnWidths[index];
applyColumnWidths(widths, true);
}
try {
var storedColumnWidths = JSON.parse(window.localStorage.getItem(columnStorageKey) || 'null');
if (Array.isArray(storedColumnWidths) && storedColumnWidths.length === todoColumns.length) {
applyColumnWidths(storedColumnWidths, false);
} else {
applyColumnWidths(defaultColumnWidths, false);
var initialColumnWidths = columnWidthsFromPreference(serverColumnWidths);
if (initialColumnWidths === null) {
try {
initialColumnWidths = columnWidthsFromPreference(
JSON.parse(window.localStorage.getItem(columnStorageKey) || 'null')
);
} catch (error) {
initialColumnWidths = null;
}
} catch (error) {
applyColumnWidths(defaultColumnWidths, false);
}
applyColumnWidths(initialColumnWidths || defaultColumnWidths, false);
todoColumnResizers.forEach(function (handle) {
var columnName = handle.getAttribute('data-sys-todo-col-resizer');

View File

@ -789,6 +789,8 @@ Route::prefix('workspace/other/systemization-todos')
Route::post('import', [SystemizationTodos::class, 'importWorkbook'])
->middleware('import')
->name('import');
Route::post('column-widths', [SystemizationTodos::class, 'saveColumnWidths'])
->name('column-widths');
Route::post('/', [SystemizationTodos::class, 'store'])->name('store');
Route::get('history', [SystemizationTodos::class, 'history'])->name('history');
Route::patch('{todo}/category', [SystemizationTodos::class, 'updateCategory'])

View File

@ -175,6 +175,52 @@ class SystemizationTodosTest extends FeatureTestCase
$this->assertSoftDeleted('business_systemization_todos', ['id' => $todo->id]);
}
public function test_column_widths_are_saved_as_a_shared_page_preference(): void
{
$this->loginAs();
$widths = [
'type' => 96,
'section' => 228,
'sequence' => 98,
'title' => 360,
'owner' => 112,
'assignee' => 116,
'start_date' => 120,
'due_date' => 120,
'reminder' => 118,
'status' => 96,
'progress_notes' => 280,
];
$this->postJson(route('business.systemization-todos.column-widths'), [
'widths' => $widths,
])->assertOk()
->assertJson([
'ok' => true,
'widths' => $widths,
]);
$response = $this->get(route('business.pages.show', [
'section' => 'other',
'page' => 'systemization-todos',
]))->assertOk();
$content = $response->viewData('content');
$this->assertSame($widths, $content['todoColumnWidths']);
$this->assertSame(
route('business.systemization-todos.column-widths'),
$content['todoRoutes']['columnWidths'],
);
$this->assertStringContainsString(
'serverColumnWidths = ' . json_encode(
$widths,
JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES,
),
(string) $response->getContent(),
);
}
public function test_long_progress_and_notes_keep_the_standard_table_row_layout(): void
{
$this->loginAs();