新增财务系统化待办与完成截图 #548

Merged
yueyang merged 1 commits from feature/yueyang-systemization-todo into main 2026-10-25 16:23:08 +08:00
13 changed files with 1390 additions and 1 deletions

View File

@ -0,0 +1,249 @@
<?php
namespace App\Business;
use App\Models\Business\SystemizationTodo;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Modules\EcommerceFinance\Services\PayablesWorkbookStreamReader;
use PhpOffice\PhpSpreadsheet\Shared\Date as ExcelDate;
use RuntimeException;
class SystemizationTodoWorkbookImporter
{
private const REQUIRED_HEADERS = [
'category' => '类型',
'section' => '内容',
'title' => '待办事项',
'owner' => '总负责人',
'assignee' => '责任人',
'start' => '开始时间',
'finish' => '完成时间',
'progress' => '完成情况',
'notes' => '备注',
];
/**
* 导入财务系统化待办。只新增系统中不存在的来源行,不覆盖日常维护后的状态与截图。
*
* @return array{created:int,skipped:int,sheet:string}
*/
public function import(string $path, int $companyId, ?int $userId = null): array
{
if (! is_file($path)) {
throw new RuntimeException('找不到待办 Excel 文件。');
}
$extension = strtolower(pathinfo($path, PATHINFO_EXTENSION));
if ($extension !== 'xlsx') {
throw new RuntimeException('系统化待办仅支持 .xlsx 文件。');
}
$reader = PayablesWorkbookStreamReader::forFile($path, $extension);
foreach ($reader->sheetNames() as $sheetIndex => $sheetName) {
$headerInfo = $this->headerMap($reader, $sheetIndex);
if ($headerInfo === null) {
continue;
}
$header = $headerInfo['columns'];
$headerRow = $headerInfo['row'];
$created = 0;
$skipped = 0;
DB::transaction(function () use (
$reader,
$sheetIndex,
$sheetName,
$header,
$headerRow,
$companyId,
$userId,
&$created,
&$skipped
): void {
$category = '';
$section = '';
foreach ($reader->rows($sheetIndex) as $rowNumber => $cells) {
if ($rowNumber <= $headerRow) {
continue;
}
$categoryValue = $this->cell($cells, $header['category'] ?? null);
if ($categoryValue !== '') {
$category = $categoryValue;
}
$sectionValue = $this->cell($cells, $header['section'] ?? null);
if ($sectionValue !== '') {
$section = $sectionValue;
}
$title = $this->cell($cells, $header['title'] ?? null);
if ($title === '') {
continue;
}
$owner = $this->cell($cells, $header['owner'] ?? null);
$assignee = $this->cell($cells, $header['assignee'] ?? null);
$sourceKey = hash(
'sha256',
implode('|', [$companyId, $sheetName, $rowNumber, $category, $section, $title, $owner, $assignee])
);
$exists = SystemizationTodo::withoutGlobalScopes()
->where('company_id', $companyId)
->where('source_key', $sourceKey)
->exists();
if ($exists) {
$skipped++;
continue;
}
$startText = $this->cell($cells, $header['start'] ?? null);
$finishText = $this->cell($cells, $header['finish'] ?? null);
[$startDate, $startNote] = $this->dateParts($startText);
[$finishDate, $finishNote] = $this->dateParts($finishText);
$progress = $this->cell($cells, $header['progress'] ?? null);
$status = $this->status($progress, $startText, $finishText);
$completedAt = $status === SystemizationTodo::STATUS_COMPLETED
? ($finishDate ?? now()->toDateString())
: null;
SystemizationTodo::withoutGlobalScopes()->create([
'company_id' => $companyId,
'category' => $category !== '' ? $category : null,
'section' => $section !== '' ? $section : null,
'title' => $title,
'owner' => $owner !== '' ? $owner : null,
'assignee' => $assignee !== '' ? $assignee : null,
'start_date' => $startDate,
'start_note' => $startNote !== '' ? $startNote : null,
'due_date' => $finishDate,
'due_note' => $finishNote !== '' ? $finishNote : null,
'status' => $status,
'completed_at' => $completedAt,
'progress' => $progress !== '' ? $progress : null,
'notes' => ($notes = $this->cell($cells, $header['notes'] ?? null)) !== '' ? $notes : null,
'sort_order' => $rowNumber,
'source_key' => $sourceKey,
'imported_at' => now(),
'created_by' => $userId,
'updated_by' => $userId,
]);
$created++;
}
});
return ['created' => $created, 'skipped' => $skipped, 'sheet' => $sheetName];
}
throw new RuntimeException(
'未找到待办表头。请确认工作表包含:类型、内容、待办事项、总负责人、责任人、开始时间、完成时间、完成情况、备注。'
);
}
/**
* @return array{row:int,columns:array<string,int>}|null
*/
private function headerMap(PayablesWorkbookStreamReader $reader, int $sheetIndex): ?array
{
foreach ($reader->rows($sheetIndex, null, 12) as $rowNumber => $cells) {
$normalized = [];
foreach ($cells as $column => $value) {
$value = $this->normalizeHeader($this->text($value));
if ($value !== '') {
$normalized[$value] = $column;
}
}
$mapped = [];
foreach (self::REQUIRED_HEADERS as $field => $label) {
$needle = $this->normalizeHeader($label);
foreach ($normalized as $header => $column) {
if ($header === $needle || str_starts_with($header, $needle)) {
$mapped[$field] = $column;
break;
}
}
}
if (isset($mapped['title'])
&& isset($mapped['owner'])
&& isset($mapped['assignee'])) {
return ['row' => $rowNumber, 'columns' => $mapped];
}
}
return null;
}
private function normalizeHeader(string $value): string
{
return preg_replace('/[\s ()()]+/u', '', $value) ?? '';
}
/**
* @param array<int,mixed> $cells
*/
private function cell(array $cells, ?int $column): string
{
return $column === null ? '' : $this->text($cells[$column] ?? null);
}
private function text(mixed $value): string
{
if ($value === null) {
return '';
}
if (is_bool($value)) {
return $value ? '1' : '0';
}
return trim(str_replace("\u{00A0}", ' ', (string) $value));
}
/**
* @return array{0:string|null,1:string}
*/
private function dateParts(string $value): array
{
$value = trim($value);
if ($value === '' || $value === '已完成') {
return [null, ''];
}
if (is_numeric($value)) {
$serial = (float) $value;
if ($serial >= 20000 && $serial <= 80000) {
return [ExcelDate::excelToDateTimeObject($serial)->format('Y-m-d'), ''];
}
}
foreach (['Y-m-d', 'Y/m/d', 'Y.m.d', 'Y年n月j日'] as $format) {
try {
$date = Carbon::createFromFormat($format, $value);
if ($date !== false) {
return [$date->format('Y-m-d'), ''];
}
} catch (\Throwable) {
// 不是标准日期,保留原值作为时间备注。
}
}
return [null, mb_substr($value, 0, 100)];
}
private function status(string $progress, string $startNote, string $finishNote): string
{
if (str_contains($progress, '已完成')) {
return SystemizationTodo::STATUS_COMPLETED;
}
if ($progress !== '') {
return str_contains($progress, '延期')
? SystemizationTodo::STATUS_DELAYED
: SystemizationTodo::STATUS_IN_PROGRESS;
}
if (str_contains($startNote, '延期') || str_contains($finishNote, '延期')) {
return SystemizationTodo::STATUS_DELAYED;
}
return SystemizationTodo::STATUS_PENDING;
}
}

View File

@ -0,0 +1,41 @@
<?php
namespace App\Console\Commands;
use App\Business\SystemizationTodoWorkbookImporter;
use Illuminate\Console\Command;
use Throwable;
class ImportSystemizationTodos extends Command
{
protected $signature = 'systemization-todos:import
{file : 待办 Excel 文件路径}
{--company=1 : 写入的 company_id}
{--user= : 操作人用户 ID}';
protected $description = '导入财务系统化待办清单';
public function handle(SystemizationTodoWorkbookImporter $importer): int
{
try {
$result = $importer->import(
(string) $this->argument('file'),
max((int) $this->option('company'), 1),
$this->option('user') !== null ? (int) $this->option('user') : null,
);
} catch (Throwable $exception) {
$this->error($exception->getMessage());
return self::FAILURE;
}
$this->info(sprintf(
'工作表 %s:新增 %d 条,跳过已存在 %d 条。',
$result['sheet'],
$result['created'],
$result['skipped'],
));
return self::SUCCESS;
}
}

View File

@ -4186,6 +4186,7 @@ class Pages extends Controller
'shipping-data' => $this->contentForShippingData(),
'return-pickup-management' => $this->contentForReturnPickupManagement(),
'withdrawal-management' => $this->contentForWithdrawalManagement(),
'systemization-todos' => app(\App\Http\Controllers\Business\SystemizationTodos::class)->page(),
'todos' => $this->contentForTodos(),
'bill-offset' => app(\Modules\EcommerceFinance\View\BillOffsetPageData::class)->page(),
'account-mapping' => $this->contentForAccountMapping(),

View File

@ -0,0 +1,284 @@
<?php
namespace App\Http\Controllers\Business;
use App\Business\SystemizationTodoWorkbookImporter;
use App\Models\Business\SystemizationTodo;
use App\Models\Business\SystemizationTodoAttachment;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
use Symfony\Component\HttpFoundation\StreamedResponse;
use Throwable;
class SystemizationTodos extends Controller
{
public function page(): array
{
$filters = $this->filters();
$query = SystemizationTodo::query()
->with('attachments')
->withCount('attachments');
if ($filters['status'] !== '') {
$query->where('status', $filters['status']);
}
if ($filters['category'] !== '') {
$query->where('category', $filters['category']);
}
if ($filters['owner'] !== '') {
$query->where(function ($builder) use ($filters): void {
$builder->where('owner', $filters['owner'])
->orWhere('assignee', $filters['owner']);
});
}
if ($filters['keyword'] !== '') {
$like = '%' . addcslashes($filters['keyword'], '%_\\') . '%';
$query->where(function ($builder) use ($like): void {
$builder->where('title', 'like', $like)
->orWhere('category', 'like', $like)
->orWhere('section', 'like', $like)
->orWhere('owner', 'like', $like)
->orWhere('assignee', 'like', $like)
->orWhere('progress', 'like', $like)
->orWhere('notes', 'like', $like);
});
}
$items = $query
->orderByRaw("CASE WHEN status = '已完成' THEN 1 ELSE 0 END")
->orderByRaw('CASE WHEN due_date IS NULL THEN 1 ELSE 0 END')
->orderBy('due_date')
->orderBy('sort_order')
->orderBy('id')
->paginate(50)
->withQueryString();
$base = SystemizationTodo::query();
$categories = SystemizationTodo::query()
->whereNotNull('category')
->where('category', '<>', '')
->distinct()
->orderBy('category')
->pluck('category');
$owners = SystemizationTodo::query()
->whereNotNull('owner')
->where('owner', '<>', '')
->pluck('owner')
->merge(
SystemizationTodo::query()
->whereNotNull('assignee')
->where('assignee', '<>', '')
->pluck('assignee')
)
->flatMap(static fn (string $value): array => array_map('trim', preg_split('/[、,,]/u', $value) ?: []))
->filter()
->unique()
->sort()
->values();
return [
'view' => 'business.pages.systemization-todos',
'title' => '系统化待办',
'items' => $items,
'todoFilters' => $filters,
'todoCategories' => $categories,
'todoOwners' => $owners,
'todoStatuses' => SystemizationTodo::STATUSES,
'todoSummary' => [
'total' => (clone $base)->count(),
'pending' => (clone $base)->where('status', SystemizationTodo::STATUS_PENDING)->count(),
'in_progress' => (clone $base)->where('status', SystemizationTodo::STATUS_IN_PROGRESS)->count(),
'completed' => (clone $base)->where('status', SystemizationTodo::STATUS_COMPLETED)->count(),
'overdue' => (clone $base)
->whereNotIn('status', [SystemizationTodo::STATUS_COMPLETED])
->whereNotNull('due_date')
->whereDate('due_date', '<', today())
->count(),
],
'todoRoutes' => [
'store' => route('business.systemization-todos.store'),
'update' => route('business.systemization-todos.update', ['todo' => '__TODO__']),
'destroy' => route('business.systemization-todos.destroy', ['todo' => '__TODO__']),
'attachment' => route('business.systemization-todos.attachments.show', [
'todo' => '__TODO__',
'attachment' => '__ATTACHMENT__',
]),
'import' => route('business.systemization-todos.import'),
],
];
}
public function store(Request $request): RedirectResponse
{
$data = $this->validated($request);
$data['company_id'] = (int) company_id();
$data['sort_order'] = (int) SystemizationTodo::query()->max('sort_order') + 1;
$data['created_by'] = user_id();
$data['updated_by'] = user_id();
$this->normalizeCompletionDates($data);
$todo = SystemizationTodo::query()->create($data);
$this->storeAttachments($request, $todo);
return $this->backToPage('待办事项已新增。');
}
public function update(Request $request, SystemizationTodo $todo): RedirectResponse
{
$data = $this->validated($request);
$data['updated_by'] = user_id();
$this->normalizeCompletionDates($data);
$removeIds = collect($request->input('remove_attachments', []))
->map(static fn (mixed $id): int => (int) $id)
->filter()
->unique()
->values();
if ($removeIds->isNotEmpty()) {
$attachments = $todo->attachments()
->where('company_id', (int) company_id())
->whereIn('id', $removeIds)
->get();
foreach ($attachments as $attachment) {
Storage::disk('uploads')->delete($attachment->path);
$attachment->delete();
}
}
$todo->update($data);
$this->storeAttachments($request, $todo);
return $this->backToPage('待办事项已更新。');
}
public function destroy(SystemizationTodo $todo): RedirectResponse
{
$todo->update(['updated_by' => user_id()]);
$todo->delete();
return $this->backToPage('待办事项已删除。');
}
public function attachment(SystemizationTodo $todo, SystemizationTodoAttachment $attachment): StreamedResponse
{
abort_unless((int) $attachment->systemization_todo_id === (int) $todo->id, 404);
abort_unless((int) $attachment->company_id === (int) company_id(), 404);
$disk = Storage::disk('uploads');
abort_unless($disk->exists($attachment->path), 404, '截图不存在。');
return $disk->response($attachment->path, $attachment->original_name, [
'Cache-Control' => 'private, no-store',
'Content-Type' => $attachment->mime_type,
'X-Content-Type-Options' => 'nosniff',
]);
}
public function importWorkbook(
Request $request,
SystemizationTodoWorkbookImporter $importer,
): RedirectResponse {
$data = $request->validate([
'source' => ['required', 'file', 'mimes:xlsx', 'max:20480'],
]);
try {
$result = $importer->import(
$data['source']->getRealPath(),
(int) company_id(),
user_id(),
);
} catch (Throwable $exception) {
report($exception);
return $this->backToPage('导入失败,请确认工作簿表头、文件格式和内容后重试。', 'error');
}
return $this->backToPage(sprintf(
'导入完成:%s,新增 %d 条,跳过已存在 %d 条。',
$result['sheet'],
$result['created'],
$result['skipped'],
));
}
private function filters(): array
{
return [
'status' => in_array(request('status'), SystemizationTodo::STATUSES, true)
? (string) request('status')
: '',
'category' => trim((string) request('category', '')),
'owner' => trim((string) request('owner', '')),
'keyword' => trim((string) request('keyword', '')),
];
}
private function validated(Request $request): array
{
return $request->validate([
'category' => ['nullable', 'string', 'max:100'],
'section' => ['nullable', 'string', 'max:255'],
'title' => ['required', 'string', 'max:5000'],
'owner' => ['nullable', 'string', 'max:255'],
'assignee' => ['nullable', 'string', 'max:255'],
'start_date' => ['nullable', 'date_format:Y-m-d'],
'due_date' => ['nullable', 'date_format:Y-m-d', 'after_or_equal:start_date'],
'status' => ['required', Rule::in(SystemizationTodo::STATUSES)],
'completed_at' => ['nullable', 'date_format:Y-m-d'],
'progress' => ['nullable', 'string', 'max:10000'],
'notes' => ['nullable', 'string', 'max:10000'],
'screenshots' => ['nullable', 'array', 'max:12'],
'screenshots.*' => ['file', 'mimes:jpg,jpeg,png,webp', 'max:10240'],
'remove_attachments' => ['nullable', 'array'],
'remove_attachments.*' => ['integer'],
]);
}
private function normalizeCompletionDates(array &$data): void
{
$completed = ($data['status'] ?? '') === SystemizationTodo::STATUS_COMPLETED;
$data['completed_at'] = $completed
? (($data['completed_at'] ?? null) ?: ($data['due_date'] ?? null) ?: now()->toDateString())
: null;
}
private function storeAttachments(Request $request, SystemizationTodo $todo): void
{
foreach ($request->file('screenshots', []) as $file) {
$extension = strtolower($file->getClientOriginalExtension());
$fileName = (string) Str::uuid() . ($extension !== '' ? '.' . $extension : '');
$path = Storage::disk('uploads')->putFileAs(
'systemization-todos/' . (int) company_id() . '/' . $todo->id,
$file,
$fileName,
);
if ($path === false) {
throw new \RuntimeException('完成截图保存失败。');
}
SystemizationTodoAttachment::query()->create([
'company_id' => (int) company_id(),
'systemization_todo_id' => $todo->id,
'path' => $path,
'original_name' => mb_substr(basename($file->getClientOriginalName()), 0, 240),
'mime_type' => $file->getMimeType() ?: 'application/octet-stream',
'size' => $file->getSize() ?: 0,
'uploaded_by' => user_id(),
]);
}
}
private function backToPage(string $message, string $type = 'success'): RedirectResponse
{
return redirect()
->route('business.pages.show', [
'section' => 'other',
'page' => 'systemization-todos',
])
->with($type === 'success' ? 'success' : 'error', $message);
}
}

View File

@ -0,0 +1,68 @@
<?php
namespace App\Models\Business;
use App\Abstracts\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
class SystemizationTodo extends Model
{
public const STATUS_PENDING = '待办';
public const STATUS_IN_PROGRESS = '进行中';
public const STATUS_COMPLETED = '已完成';
public const STATUS_DELAYED = '已延期';
public const STATUSES = [
self::STATUS_PENDING,
self::STATUS_IN_PROGRESS,
self::STATUS_COMPLETED,
self::STATUS_DELAYED,
];
protected $table = 'business_systemization_todos';
protected $fillable = [
'company_id',
'category',
'section',
'title',
'owner',
'assignee',
'start_date',
'start_note',
'due_date',
'due_note',
'status',
'completed_at',
'progress',
'notes',
'sort_order',
'source_key',
'imported_at',
'created_by',
'updated_by',
];
protected $casts = [
'company_id' => 'integer',
'start_date' => 'date',
'due_date' => 'date',
'completed_at' => 'date',
'sort_order' => 'integer',
'imported_at' => 'datetime',
];
public function attachments(): HasMany
{
return $this->hasMany(SystemizationTodoAttachment::class, 'systemization_todo_id')
->orderBy('id');
}
public function isCompleted(): bool
{
return $this->status === self::STATUS_COMPLETED;
}
}

View File

@ -0,0 +1,32 @@
<?php
namespace App\Models\Business;
use App\Abstracts\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class SystemizationTodoAttachment extends Model
{
protected $table = 'business_systemization_todo_attachments';
protected $fillable = [
'company_id',
'systemization_todo_id',
'path',
'original_name',
'mime_type',
'size',
'uploaded_by',
];
protected $casts = [
'company_id' => 'integer',
'systemization_todo_id' => 'integer',
'size' => 'integer',
];
public function todo(): BelongsTo
{
return $this->belongsTo(SystemizationTodo::class, 'systemization_todo_id');
}
}

View File

@ -44,6 +44,7 @@ class SidebarMenu
['company-management', 'company-progress', '公司进度表', 'timeline'],
['store-management', 'change-progress', '店铺变更进度表', 'swap_horiz'],
['trademark-management', 'trademark-progress', '商标进度表', 'format_list_bulleted'],
['other', 'systemization-todos', '系统化待办', 'checklist'],
['workspace', 'tasks', '待办事项', 'assignment_turned_in'],
['workspace', 'warnings', '预警页面', 'warning_amber'],
]],

View File

@ -956,6 +956,29 @@ $navigation = [
'title' => '其他',
'icon' => 'more_horiz',
'pages' => [
[
'slug' => 'systemization-todos',
'title' => '系统化待办',
'directory' => [
'icon' => 'checklist',
'status' => '持续更新',
'description' => '财务系统化工作的日常待办、责任人、进度和完成截图。',
'empty' => '暂时没有系统化待办。',
'fields' => [
['name' => 'category', 'label' => '类型'],
['name' => 'section', 'label' => '内容'],
['name' => 'title', 'label' => '待办事项'],
['name' => 'owner', 'label' => '总负责人'],
['name' => 'assignee', 'label' => '责任人'],
['name' => 'start_date', 'label' => '开始时间', 'kind' => 'date'],
['name' => 'due_date', 'label' => '完成时间', 'kind' => 'date'],
['name' => 'status', 'label' => '状态'],
['name' => 'progress', 'label' => '完成情况'],
['name' => 'attachments', 'label' => '完成截图'],
['name' => 'notes', 'label' => '备注'],
],
],
],
[
'slug' => 'todos',
'title' => '待办事项',

View File

@ -0,0 +1,73 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
if (! Schema::hasTable('business_systemization_todos')) {
Schema::create('business_systemization_todos', function (Blueprint $table): void {
$table->bigIncrements('id');
$table->unsignedInteger('company_id')->default(1);
$table->string('category', 100)->nullable();
$table->string('section', 255)->nullable();
$table->text('title');
$table->string('owner', 255)->nullable();
$table->string('assignee', 255)->nullable();
$table->date('start_date')->nullable();
$table->string('start_note', 100)->nullable();
$table->date('due_date')->nullable();
$table->string('due_note', 100)->nullable();
$table->string('status', 20)->default('待办');
$table->date('completed_at')->nullable();
$table->text('progress')->nullable();
$table->text('notes')->nullable();
$table->unsignedInteger('sort_order')->default(0);
$table->string('source_key', 64)->nullable();
$table->timestamp('imported_at')->nullable();
$table->unsignedBigInteger('created_by')->nullable();
$table->unsignedBigInteger('updated_by')->nullable();
$table->timestamps();
$table->softDeletes();
$table->index(['company_id', 'status'], 'systemization_todos_company_status_index');
$table->index(['company_id', 'category'], 'systemization_todos_company_category_index');
$table->index(['company_id', 'due_date'], 'systemization_todos_company_due_index');
$table->unique(['company_id', 'source_key'], 'systemization_todos_company_source_unique');
});
}
if (! Schema::hasTable('business_systemization_todo_attachments')) {
Schema::create('business_systemization_todo_attachments', function (Blueprint $table): void {
$table->bigIncrements('id');
$table->unsignedInteger('company_id')->default(1);
$table->unsignedBigInteger('systemization_todo_id');
$table->string('path');
$table->string('original_name');
$table->string('mime_type', 100);
$table->unsignedBigInteger('size')->default(0);
$table->unsignedBigInteger('uploaded_by')->nullable();
$table->timestamps();
$table->softDeletes();
$table->index(
['company_id', 'systemization_todo_id'],
'systemization_todo_attachments_company_todo_index'
);
$table->foreign('systemization_todo_id')
->references('id')
->on('business_systemization_todos')
->cascadeOnDelete();
});
}
}
public function down(): void
{
Schema::dropIfExists('business_systemization_todo_attachments');
Schema::dropIfExists('business_systemization_todos');
}
};

View File

@ -0,0 +1,443 @@
<section class="biz-card mt-6 sys-todo-page" aria-label="系统化待办">
<style>
.sys-todo-page { --sys-gold:#856632; --sys-gold-soft:#f7f0de; --sys-border:#d8d2c7; --sys-muted:#666; }
.sys-todo-page .biz-card-head { align-items:flex-start; gap:1rem; }
.sys-todo-page__head { display:flex; flex-wrap:wrap; align-items:flex-start; gap:1rem; width:100%; }
.sys-todo-page__head-main { min-width:20rem; }
.sys-todo-page__head-actions { display:flex; flex-wrap:wrap; gap:.5rem; margin-left:auto; }
.sys-todo-page__button { display:inline-flex; align-items:center; justify-content:center; gap:.3rem; min-height:2.125rem; padding:0 .85rem; border:1px solid var(--sys-border); border-radius:.5rem; background:#fff; color:#171717; font:inherit; font-size:.8125rem; cursor:pointer; text-decoration:none; }
.sys-todo-page__button:hover { background:var(--sys-gold-soft); border-color:#c7b687; }
.sys-todo-page__button--primary { border-color:var(--sys-gold); background:var(--sys-gold); color:#fff; }
.sys-todo-page__button--primary:hover { background:#6a5129; color:#fff; }
.sys-todo-page__button .material-icons-outlined { font-size:1rem; }
.sys-todo-page__intro { margin:.35rem 0 0; color:var(--sys-muted); font-size:.75rem; line-height:1.6; }
.sys-todo-summary { display:grid; grid-template-columns:repeat(5,minmax(0,1fr)); gap:.75rem; margin:1rem 0; }
.sys-todo-summary__item { padding:.75rem .85rem; border:1px solid #e6dfd2; border-radius:.6rem; background:#fffdf9; }
.sys-todo-summary__label { color:var(--sys-muted); font-size:.72rem; }
.sys-todo-summary__value { margin-top:.15rem; color:#171717; font-size:1.25rem; 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:grid; grid-template-columns:minmax(8rem,.8fr) minmax(10rem,1fr) minmax(10rem,1fr) minmax(18rem,2fr) auto; gap:.65rem .75rem; align-items:end; margin:0 0 1rem; padding:.85rem; border:1px solid #e6dfd2; border-radius:.65rem; background:#fcfaf5; }
.sys-todo-filter label { display:grid; gap:.3rem; color:#595959; font-size:.75rem; }
.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__actions { display:flex; gap:.45rem; }
.sys-todo-table-wrap { overflow:auto; border:1px solid var(--sys-border); border-radius:.65rem; background:#fff; }
.sys-todo-table { width:100%; min-width:94rem; border-collapse:collapse; font-size:.78rem; }
.sys-todo-table th { position:sticky; top:0; z-index:1; padding:.65rem .55rem; border-bottom:1px solid var(--sys-border); background:#f3ece0; color:#3d3a35; text-align:left; white-space:nowrap; }
.sys-todo-table td { padding:.6rem .55rem; border-bottom:1px solid #eee7da; color:#2f2a23; vertical-align:top; }
.sys-todo-table tbody tr:hover { background:#fdfbf6; }
.sys-todo-table__title, .sys-todo-table__progress, .sys-todo-table__notes { min-width:14rem; max-width:28rem; white-space:pre-wrap; overflow-wrap:anywhere; line-height:1.55; }
.sys-todo-table__title { min-width:24rem; font-weight:600; }
.sys-todo-badge { display:inline-flex; align-items:center; padding:.1rem .45rem; border:1px solid #dfd6c5; border-radius:999px; background:#faf6ee; color:#51483c; white-space:nowrap; }
.sys-todo-badge--progress { border-color:#dbbf7a; background:#fff7df; color:#935f07; }
.sys-todo-badge--completed { border-color:#bad0ac; background:#f2f8ed; color:#53793e; }
.sys-todo-badge--delayed { border-color:#e7c3c3; background:#fff3f3; color:#b80000; }
.sys-todo-badge--overdue { border-color:#e7c3c3; color:#b80000; }
.sys-todo-shots { display:flex; flex-wrap:wrap; gap:.3rem; max-width:13rem; }
.sys-todo-shots a { display:block; width:4.25rem; height:3.2rem; border:1px solid #ddd4c4; border-radius:.35rem; overflow:hidden; background:#faf8f3; }
.sys-todo-shots img { width:100%; height:100%; object-fit:cover; }
.sys-todo-row-actions { display:flex; flex-wrap:wrap; gap:.35rem; }
.sys-todo-row-actions button, .sys-todo-row-actions a { padding:.2rem .45rem; border:1px solid var(--sys-border); border-radius:.35rem; background:#fff; color:#51483c; font:inherit; font-size:.72rem; text-decoration:none; cursor:pointer; }
.sys-todo-row-actions button:hover, .sys-todo-row-actions a:hover { border-color:#c7b687; background:var(--sys-gold-soft); }
.sys-todo-empty { padding:2rem 1rem; color:#777; text-align:center; }
.sys-todo-pagination { display:flex; flex-wrap:wrap; align-items:center; justify-content:space-between; gap:.75rem; padding:.85rem 0 0; color:#595959; font-size:.75rem; }
.sys-todo-pagination__actions { display:flex; gap:.5rem; }
.sys-todo-pagination a, .sys-todo-pagination span { padding:.3rem .55rem; border:1px solid var(--sys-border); border-radius:.35rem; background:#fff; color:#171717; text-decoration:none; }
.sys-todo-flash { margin:0 0 .85rem; padding:.65rem .85rem; border:1px solid #bad0ac; border-radius:.45rem; background:#f2f8ed; color:#53793e; font-size:.8125rem; }
.sys-todo-flash--error { border-color:#e7c3c3; background:#fff3f3; color:#b80000; }
.sys-todo-import { margin:0 0 1rem; border:1px solid #e6dfd2; border-radius:.65rem; background:#fcfaf5; }
.sys-todo-import summary { padding:.7rem .9rem; color:#51483c; font-size:.8125rem; cursor:pointer; }
.sys-todo-import form { display:flex; flex-wrap:wrap; align-items:end; gap:.65rem; padding:0 .9rem .9rem; }
.sys-todo-import label { display:grid; gap:.3rem; color:#595959; font-size:.75rem; }
.sys-todo-import input { font:inherit; font-size:.8125rem; }
.sys-todo-dialog { width:min(64rem,calc(100vw - 2rem)); max-height:90vh; padding:0; border:1px solid var(--sys-border); border-radius:1rem; background:#fff; color:#171717; overflow:hidden; }
.sys-todo-dialog::backdrop { background:rgba(23,23,23,.5); }
.sys-todo-dialog__head { display:flex; align-items:center; gap:1rem; padding:1rem 1.25rem; border-bottom:1px solid #e8e0d2; background:#faf6ee; }
.sys-todo-dialog__head h2 { margin:0; font-size:1rem; }
.sys-todo-dialog__close { margin-left:auto; width:2rem; height:2rem; padding:0; border:1px solid var(--sys-border); border-radius:999px; background:#fff; color:#595959; font-size:1.2rem; cursor:pointer; }
.sys-todo-dialog__body { max-height:calc(90vh - 9rem); overflow:auto; padding:1rem 1.25rem; }
.sys-todo-form-grid { display:grid; grid-template-columns:repeat(12,minmax(0,1fr)); gap:.75rem 1rem; }
.sys-todo-field { display:grid; gap:.3rem; min-width:0; color:#595959; font-size:.75rem; }
.sys-todo-field--3 { grid-column:span 3; }
.sys-todo-field--4 { grid-column:span 4; }
.sys-todo-field--6 { grid-column:span 6; }
.sys-todo-field--12 { grid-column:span 12; }
.sys-todo-field input, .sys-todo-field select, .sys-todo-field textarea { width:100%; padding:.5rem .6rem; border:1px solid var(--sys-border); border-radius:.45rem; background:#fff; color:#171717; font:inherit; font-size:.8125rem; }
.sys-todo-field input, .sys-todo-field select { min-height:2.15rem; }
.sys-todo-field textarea { min-height:5.5rem; resize:vertical; line-height:1.55; }
.sys-todo-field input[type="file"] { padding:.35rem; }
.sys-todo-upload-note { color:#777; font-size:.72rem; }
.sys-todo-field.is-dragging { outline:2px dashed var(--sys-gold); outline-offset:3px; border-radius:.55rem; background:var(--sys-gold-soft); }
.sys-todo-existing { display:flex; flex-wrap:wrap; gap:.55rem; margin-top:.45rem; }
.sys-todo-existing__item { display:grid; gap:.25rem; width:8rem; padding:.4rem; border:1px solid #e4dccd; border-radius:.45rem; background:#fcfaf5; font-size:.7rem; }
.sys-todo-existing__item img { width:100%; height:5rem; object-fit:cover; border-radius:.3rem; background:#fff; }
.sys-todo-existing__item label { display:flex; align-items:center; gap:.25rem; color:#b80000; }
.sys-todo-dialog__foot { display:flex; justify-content:flex-end; gap:.5rem; padding:.75rem 1.25rem; border-top:1px solid #e8e0d2; background:#fcfaf5; }
.sys-todo-dialog__foot button { min-height:2.1rem; padding:0 .9rem; border:1px solid var(--sys-border); border-radius:.45rem; background:#fff; font:inherit; font-size:.8125rem; cursor:pointer; }
.sys-todo-dialog__foot button[type="submit"] { border-color:var(--sys-gold); background:var(--sys-gold); color:#fff; }
@media (max-width:1100px) { .sys-todo-summary { grid-template-columns:repeat(3,minmax(0,1fr)); } .sys-todo-filter { grid-template-columns:repeat(2,minmax(0,1fr)); } .sys-todo-filter__actions { justify-self:end; } }
@media (max-width:720px) { .sys-todo-summary { grid-template-columns:repeat(2,minmax(0,1fr)); } .sys-todo-filter { grid-template-columns:1fr; } .sys-todo-filter__actions { justify-self:stretch; } .sys-todo-filter__actions .sys-todo-page__button { flex:1; } .sys-todo-field--3,.sys-todo-field--4,.sys-todo-field--6 { grid-column:span 12; } }
</style>
<div class="biz-card-head">
<div class="sys-todo-page__head">
<div class="sys-todo-page__head-main">
<div class="biz-card-title">
<span class="material-icons-outlined" aria-hidden="true">checklist</span>
<span>财务系统化待办</span>
</div>
<p class="sys-todo-page__intro">日常过会使用。新增事项、跟进责任人和完成情况,截图直接附在该事项下。</p>
</div>
<div class="sys-todo-page__head-actions">
<button type="button" class="sys-todo-page__button sys-todo-page__button--primary" data-sys-todo-create>
<span class="material-icons-outlined" aria-hidden="true">add</span>新增待办
</button>
</div>
</div>
</div>
@if (is_string(session('success')) && session('success') !== '')
<div class="sys-todo-flash">{{ session('success') }}</div>
@endif
@if (is_string(session('error')) && session('error') !== '')
<div class="sys-todo-flash sys-todo-flash--error">{{ session('error') }}</div>
@endif
<div class="sys-todo-summary" aria-label="系统化待办统计">
<div class="sys-todo-summary__item"><div class="sys-todo-summary__label">全部</div><div class="sys-todo-summary__value">{{ number_format($todoSummary['total']) }}</div></div>
<div class="sys-todo-summary__item"><div class="sys-todo-summary__label">待办</div><div class="sys-todo-summary__value">{{ number_format($todoSummary['pending']) }}</div></div>
<div class="sys-todo-summary__item"><div class="sys-todo-summary__label">进行中</div><div class="sys-todo-summary__value">{{ number_format($todoSummary['in_progress']) }}</div></div>
<div class="sys-todo-summary__item is-success"><div class="sys-todo-summary__label">已完成</div><div class="sys-todo-summary__value">{{ number_format($todoSummary['completed']) }}</div></div>
<div class="sys-todo-summary__item is-warning"><div class="sys-todo-summary__label">已逾期</div><div class="sys-todo-summary__value">{{ number_format($todoSummary['overdue']) }}</div></div>
</div>
<details class="sys-todo-import">
<summary>从 Excel 导入现有待办(只新增,不覆盖系统内已有记录)</summary>
<form method="POST" action="{{ $todoRoutes['import'] }}" enctype="multipart/form-data">
@csrf
<label>待办 Excel<input type="file" name="source" accept=".xlsx" required></label>
<button type="submit" class="sys-todo-page__button">导入</button>
</form>
</details>
<form class="sys-todo-filter" method="GET" action="{{ request()->url() }}">
<label>状态<select name="status">
<option value="">全部状态</option>
@foreach ($todoStatuses as $status)
<option value="{{ $status }}" @selected($todoFilters['status'] === $status)>{{ $status }}</option>
@endforeach
</select></label>
<label>类型<select name="category">
<option value="">全部类型</option>
@foreach ($todoCategories as $category)
<option value="{{ $category }}" @selected($todoFilters['category'] === $category)>{{ $category }}</option>
@endforeach
</select></label>
<label>责任人<select name="owner">
<option value="">全部责任人</option>
@foreach ($todoOwners as $owner)
<option value="{{ $owner }}" @selected($todoFilters['owner'] === $owner)>{{ $owner }}</option>
@endforeach
</select></label>
<label>关键词<input type="search" name="keyword" value="{{ $todoFilters['keyword'] }}" placeholder="事项、内容、责任人、进展或备注"></label>
<div class="sys-todo-filter__actions">
<button type="submit" class="sys-todo-page__button sys-todo-page__button--primary">筛选</button>
<a class="sys-todo-page__button" href="{{ request()->url() }}">重置</a>
</div>
</form>
<div class="sys-todo-table-wrap">
<table class="sys-todo-table" aria-label="财务系统化待办清单">
<thead>
<tr>
<th>序号</th><th>类型</th><th>内容</th><th>待办事项</th><th>总负责人</th><th>责任人</th>
<th>开始时间</th><th>完成时间</th><th>状态</th><th>完成情况</th><th>完成截图</th><th>备注</th><th>操作</th>
</tr>
</thead>
<tbody>
@forelse ($items as $item)
@php
$isOverdue = ! $item->isCompleted()
&& $item->due_date !== null
&& $item->due_date->isBefore(today());
$statusClass = match ($item->status) {
\App\Models\Business\SystemizationTodo::STATUS_IN_PROGRESS => 'sys-todo-badge--progress',
\App\Models\Business\SystemizationTodo::STATUS_COMPLETED => 'sys-todo-badge--completed',
\App\Models\Business\SystemizationTodo::STATUS_DELAYED => 'sys-todo-badge--delayed',
default => '',
};
$todoPayload = [
'id' => $item->id,
'category' => $item->category ?? '',
'section' => $item->section ?? '',
'title' => $item->title,
'owner' => $item->owner ?? '',
'assignee' => $item->assignee ?? '',
'start_date' => $item->start_date?->format('Y-m-d') ?? '',
'due_date' => $item->due_date?->format('Y-m-d') ?? '',
'status' => $item->status,
'completed_at' => $item->completed_at?->format('Y-m-d') ?? '',
'progress' => $item->progress ?? '',
'notes' => $item->notes ?? '',
'attachments' => $item->attachments->map(static fn ($attachment): array => [
'id' => $attachment->id,
'name' => $attachment->original_name,
'url' => route('business.systemization-todos.attachments.show', [
'todo' => $item->id,
'attachment' => $attachment->id,
]),
])->values()->all(),
];
@endphp
<tr>
<td>{{ $items->firstItem() + $loop->index }}</td>
<td>{{ $item->category ?: '—' }}</td>
<td>{{ $item->section ?: '—' }}</td>
<td class="sys-todo-table__title">{!! nl2br(e($item->title)) !!}</td>
<td>{{ $item->owner ?: '—' }}</td>
<td>{{ $item->assignee ?: '—' }}</td>
<td>{{ $item->start_date?->format('Y-m-d') ?: ($item->start_note ?: '—') }}</td>
<td>
{{ $item->due_date?->format('Y-m-d') ?: ($item->due_note ?: '—') }}
@if ($isOverdue)<br><span class="sys-todo-badge sys-todo-badge--overdue">已逾期</span>@endif
</td>
<td><span class="sys-todo-badge {{ $statusClass }}">{{ $item->status }}</span></td>
<td class="sys-todo-table__progress">{!! nl2br(e($item->progress ?: '—')) !!}</td>
<td>
@if ($item->attachments->isNotEmpty())
<div class="sys-todo-shots">
@foreach ($item->attachments as $attachment)
<a href="{{ route('business.systemization-todos.attachments.show', ['todo' => $item->id, 'attachment' => $attachment->id]) }}"
target="_blank" rel="noopener" title="{{ $attachment->original_name }}">
<img src="{{ route('business.systemization-todos.attachments.show', ['todo' => $item->id, 'attachment' => $attachment->id]) }}"
alt="{{ $attachment->original_name }}" loading="lazy">
</a>
@endforeach
</div>
@else
—
@endif
</td>
<td class="sys-todo-table__notes">{!! nl2br(e($item->notes ?: '—')) !!}</td>
<td>
<div class="sys-todo-row-actions">
<button type="button" data-sys-todo-edit="{{ base64_encode(json_encode($todoPayload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)) }}">编辑</button>
<form method="POST" action="{{ route('business.systemization-todos.destroy', ['todo' => $item->id]) }}" onsubmit="return confirm('确定删除这条待办吗?');">
@csrf
@method('DELETE')
<button type="submit">删除</button>
</form>
</div>
</td>
</tr>
@empty
<tr><td colspan="13"><div class="sys-todo-empty">当前筛选条件下没有待办事项。</div></td></tr>
@endforelse
</tbody>
</table>
</div>
@if ($items->hasPages())
<div class="sys-todo-pagination">
<span>共 {{ number_format($items->total()) }} 条 · 第 {{ $items->currentPage() }} / {{ $items->lastPage() }} 页</span>
<span class="sys-todo-pagination__actions">
@if ($items->onFirstPage())
<span>上一页</span>
@else
<a href="{{ $items->previousPageUrl() }}">上一页</a>
@endif
@if ($items->hasMorePages())
<a href="{{ $items->nextPageUrl() }}">下一页</a>
@else
<span>下一页</span>
@endif
</span>
</div>
@endif
<dialog class="sys-todo-dialog" id="sysTodoDialog" aria-labelledby="sysTodoDialogTitle">
<form method="POST" enctype="multipart/form-data" data-sys-todo-form>
@csrf
<input type="hidden" name="_method" value="POST" data-sys-todo-method>
<div class="sys-todo-dialog__head">
<h2 id="sysTodoDialogTitle">新增待办</h2>
<button type="button" class="sys-todo-dialog__close" data-sys-todo-close aria-label="关闭">×</button>
</div>
<div class="sys-todo-dialog__body">
<div class="sys-todo-form-grid">
<label class="sys-todo-field sys-todo-field--3">类型<input name="category" maxlength="100" list="sysTodoCategories"></label>
<label class="sys-todo-field sys-todo-field--4">内容<input name="section" maxlength="255"></label>
<label class="sys-todo-field sys-todo-field--3">状态<select name="status" required>@foreach ($todoStatuses as $status)<option value="{{ $status }}">{{ $status }}</option>@endforeach</select></label>
<label class="sys-todo-field sys-todo-field--3">总负责人<input name="owner" maxlength="255" list="sysTodoOwners"></label>
<label class="sys-todo-field sys-todo-field--3">责任人<input name="assignee" maxlength="255" list="sysTodoOwners"></label>
<label class="sys-todo-field sys-todo-field--3">开始时间<input type="date" name="start_date"></label>
<label class="sys-todo-field sys-todo-field--3">完成时间<input type="date" name="due_date"></label>
<label class="sys-todo-field sys-todo-field--6">待办事项<textarea name="title" maxlength="5000" required></textarea></label>
<label class="sys-todo-field sys-todo-field--6">完成情况<textarea name="progress" maxlength="10000"></textarea></label>
<label class="sys-todo-field sys-todo-field--6">备注<textarea name="notes" maxlength="10000"></textarea></label>
<div class="sys-todo-field sys-todo-field--6" data-sys-todo-upload>
<label for="sysTodoScreenshots">完成截图</label>
<input id="sysTodoScreenshots" type="file" name="screenshots[]" accept=".jpg,.jpeg,.png,.webp" multiple>
<span class="sys-todo-upload-note" data-sys-todo-upload-note>可选择、拖入或直接粘贴截图,每张不超过 10 MB。</span>
<span class="sys-todo-existing" data-sys-todo-existing></span>
</div>
</div>
</div>
<div class="sys-todo-dialog__foot">
<button type="button" data-sys-todo-close>取消</button>
<button type="submit">保存</button>
</div>
</form>
</dialog>
<datalist id="sysTodoCategories">@foreach ($todoCategories as $category)<option value="{{ $category }}">@endforeach</datalist>
<datalist id="sysTodoOwners">@foreach ($todoOwners as $owner)<option value="{{ $owner }}">@endforeach</datalist>
<script>
(function () {
var dialog = document.getElementById('sysTodoDialog');
var form = dialog.querySelector('[data-sys-todo-form]');
var methodInput = dialog.querySelector('[data-sys-todo-method]');
var title = document.getElementById('sysTodoDialogTitle');
var existing = dialog.querySelector('[data-sys-todo-existing]');
var uploadField = dialog.querySelector('[data-sys-todo-upload]');
var screenshotInput = document.getElementById('sysTodoScreenshots');
var uploadNote = dialog.querySelector('[data-sys-todo-upload-note]');
var routes = @json($todoRoutes, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
var currentTodoId = null;
function decodePayload(value) {
var binary = atob(value);
var bytes = new Uint8Array(binary.length);
for (var i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
return JSON.parse(new TextDecoder().decode(bytes));
}
function setValue(name, value) {
var field = form.querySelector('[name="' + name + '"]');
if (field) field.value = value || '';
}
function appendScreenshotFiles(files) {
var images = Array.prototype.slice.call(files || []).filter(function (file) {
return /^image\/(jpeg|png|webp)$/.test(file.type);
});
if (!images.length) return;
var transfer = new DataTransfer();
Array.prototype.slice.call(screenshotInput.files || []).forEach(function (file) {
transfer.items.add(file);
});
images.forEach(function (file) {
transfer.items.add(file);
});
screenshotInput.files = transfer.files;
uploadNote.textContent = '已选择 ' + screenshotInput.files.length + ' 张截图,保存后上传。';
}
function openDialog(payload) {
currentTodoId = payload && payload.id ? payload.id : null;
form.reset();
methodInput.value = currentTodoId ? 'PATCH' : 'POST';
form.action = currentTodoId ? routes.update.replace('__TODO__', currentTodoId) : routes.store;
title.textContent = currentTodoId ? '编辑待办' : '新增待办';
if (payload) {
setValue('category', payload.category);
setValue('section', payload.section);
setValue('title', payload.title);
setValue('owner', payload.owner);
setValue('assignee', payload.assignee);
setValue('start_date', payload.start_date);
setValue('due_date', payload.due_date);
setValue('status', payload.status);
setValue('completed_at', payload.completed_at);
setValue('progress', payload.progress);
setValue('notes', payload.notes);
} else {
setValue('status', '待办');
}
existing.replaceChildren();
((payload && payload.attachments) || []).forEach(function (attachment) {
var item = document.createElement('span');
item.className = 'sys-todo-existing__item';
var link = document.createElement('a');
link.href = attachment.url;
link.target = '_blank';
link.rel = 'noopener';
var image = document.createElement('img');
image.src = attachment.url;
image.alt = attachment.name;
link.appendChild(image);
item.appendChild(link);
var label = document.createElement('label');
var checkbox = document.createElement('input');
checkbox.type = 'checkbox';
checkbox.name = 'remove_attachments[]';
checkbox.value = attachment.id;
label.appendChild(checkbox);
label.appendChild(document.createTextNode('删除截图'));
item.appendChild(label);
existing.appendChild(item);
});
if (typeof dialog.showModal === 'function') dialog.showModal();
else dialog.setAttribute('open', '');
uploadNote.textContent = '可选择、拖入或直接粘贴截图,每张不超过 10 MB。';
window.setTimeout(function () {
var titleField = form.querySelector('[name="title"]');
if (titleField) titleField.focus();
}, 0);
}
function closeDialog() {
if (typeof dialog.close === 'function') dialog.close();
else dialog.removeAttribute('open');
}
document.addEventListener('click', function (event) {
var create = event.target.closest('[data-sys-todo-create]');
if (create) {
openDialog(null);
return;
}
var edit = event.target.closest('[data-sys-todo-edit]');
if (edit) {
openDialog(decodePayload(edit.getAttribute('data-sys-todo-edit')));
return;
}
if (event.target.closest('[data-sys-todo-close]')) closeDialog();
});
dialog.addEventListener('click', function (event) {
if (event.target === dialog) closeDialog();
});
dialog.addEventListener('paste', function (event) {
var files = Array.prototype.slice.call(event.clipboardData?.items || [])
.filter(function (item) { return item.kind === 'file'; })
.map(function (item) { return item.getAsFile(); })
.filter(Boolean);
if (files.length) {
event.preventDefault();
appendScreenshotFiles(files);
}
});
uploadField.addEventListener('dragover', function (event) {
event.preventDefault();
uploadField.classList.add('is-dragging');
});
uploadField.addEventListener('dragleave', function () {
uploadField.classList.remove('is-dragging');
});
uploadField.addEventListener('drop', function (event) {
event.preventDefault();
uploadField.classList.remove('is-dragging');
appendScreenshotFiles(event.dataTransfer?.files || []);
});
screenshotInput.addEventListener('change', function () {
if (screenshotInput.files.length) {
uploadNote.textContent = '已选择 ' + screenshotInput.files.length + ' 张截图,保存后上传。';
}
});
})();
</script>
</section>

View File

@ -14,6 +14,7 @@ use App\Http\Controllers\Business\ProductPrices;
use App\Http\Controllers\Business\SupplierPayableActions;
use App\Http\Controllers\Business\PayablesFinalActions;
use App\Http\Controllers\Business\SupplierReconciliation as SupplierReconciliationActions;
use App\Http\Controllers\Business\SystemizationTodos;
use App\Http\Controllers\Business\WithdrawalManagement;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;
@ -758,6 +759,26 @@ Route::post('workspace/financial-ledger/accounts/keep-todo', [\App\Http\Controll
->middleware('business.page:financial-operations,ledger')
->name('business.financial-ledger.accounts.keep-todo');
Route::prefix('workspace/other/systemization-todos')
->name('business.systemization-todos.')
->middleware('business.page:other,systemization-todos')
->group(function (): void {
Route::post('import', [SystemizationTodos::class, 'importWorkbook'])
->middleware('import')
->name('import');
Route::post('/', [SystemizationTodos::class, 'store'])->name('store');
Route::patch('{todo}', [SystemizationTodos::class, 'update'])
->whereNumber('todo')
->name('update');
Route::delete('{todo}', [SystemizationTodos::class, 'destroy'])
->whereNumber('todo')
->name('destroy');
Route::get('{todo}/attachments/{attachment}', [SystemizationTodos::class, 'attachment'])
->whereNumber('todo')
->whereNumber('attachment')
->name('attachments.show');
});
Route::get('workspace/financial-ledger/{page?}/{tab?}', function (Request $request, ?string $page = null, ?string $tab = null) {
abort_unless($page === null || $page === 'ledger', 404);

View File

@ -636,7 +636,7 @@ class ManagementCrudTest extends FeatureTestCase
array_column($sections['store-management']['pages'], 'slug')
);
$this->assertSame(
['todos', 'warnings'],
['systemization-todos', 'todos', 'warnings'],
array_column($sections['other']['pages'], 'slug')
);
@ -711,6 +711,7 @@ class ManagementCrudTest extends FeatureTestCase
['section' => 'trademark-management', 'page' => 'trademark-progress', 'headers' => ['商标号', '是否完成', '办理人']],
['section' => 'store-management', 'page' => 'store-report', 'headers' => ['ERP系统店铺名称', '店铺名称', '店铺ID(唯一值)', '1月销量', '12月销量']],
['section' => 'store-management', 'page' => 'change-progress', 'headers' => ['接替公司', '完成时间', '办理人']],
['section' => 'other', 'page' => 'systemization-todos', 'headers' => ['类型', '内容', '待办事项', '总负责人', '责任人', '状态', '完成截图']],
['section' => 'other', 'page' => 'todos', 'headers' => ['待办编号', '是否逾期', '关闭日期']],
['section' => 'other', 'page' => 'warnings', 'headers' => ['风险等级', '影响金额', '处理证据']],
];

View File

@ -0,0 +1,152 @@
<?php
namespace Tests\Feature\Business;
use App\Business\SystemizationTodoWorkbookImporter;
use App\Models\Business\SystemizationTodo;
use App\Models\Business\SystemizationTodoAttachment;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
use Tests\Feature\FeatureTestCase;
class SystemizationTodosTest extends FeatureTestCase
{
public function test_page_supports_daily_items_completion_progress_and_screenshots(): void
{
Storage::fake('uploads');
$this->loginAs()
->get(route('business.pages.show', [
'section' => 'other',
'page' => 'systemization-todos',
]))
->assertOk()
->assertSeeText('财务系统化待办')
->assertSeeText('新增待办')
->assertSeeText('从 Excel 导入现有待办')
->assertSee('data-sys-todo-create', false);
$this->post(route('business.systemization-todos.store'), [
'category' => '收入',
'section' => '平台收入核对',
'title' => '核对淘系收入与平台账单差异',
'owner' => '晨曦',
'assignee' => '栎阳',
'start_date' => '2026-10-25',
'due_date' => '2026-10-28',
'status' => SystemizationTodo::STATUS_IN_PROGRESS,
'progress' => '已拉取账单,待核对订单',
'notes' => '周会跟进',
'screenshots' => [
$this->pngUpload('first.png'),
$this->pngUpload('existing.png'),
],
])->assertRedirect(route('business.pages.show', [
'section' => 'other',
'page' => 'systemization-todos',
]));
$todo = SystemizationTodo::query()->firstOrFail();
$this->assertSame('收入', $todo->category);
$this->assertSame(SystemizationTodo::STATUS_IN_PROGRESS, $todo->status);
$this->assertSame(2, $todo->attachments()->count());
$firstAttachment = $todo->attachments()->firstOrFail();
$existingAttachment = $todo->attachments()->reorder()->orderByDesc('id')->firstOrFail();
Storage::disk('uploads')->assertExists($firstAttachment->path);
Storage::disk('uploads')->assertExists($existingAttachment->path);
$this->get(route('business.systemization-todos.attachments.show', [
'todo' => $todo->id,
'attachment' => $firstAttachment->id,
]))->assertOk()->assertHeader('content-type', 'image/png');
$this->patch(route('business.systemization-todos.update', ['todo' => $todo->id]), [
'category' => '收入',
'section' => '平台收入核对',
'title' => '核对淘系收入与平台账单差异',
'owner' => '晨曦',
'assignee' => '栎阳',
'start_date' => '2026-10-25',
'due_date' => '2026-10-27',
'status' => SystemizationTodo::STATUS_COMPLETED,
'progress' => '已完成核对并留档',
'notes' => '周会确认关闭',
'remove_attachments' => [$firstAttachment->id],
'screenshots' => [$this->pngUpload('completed.png')],
])->assertRedirect();
$todo->refresh();
$this->assertSame(SystemizationTodo::STATUS_COMPLETED, $todo->status);
$this->assertSame('2026-10-27', $todo->completed_at?->format('Y-m-d'));
Storage::disk('uploads')->assertMissing($firstAttachment->path);
$this->assertTrue(
Storage::disk('uploads')->exists($existingAttachment->path),
'保留截图丢失:first=' . $firstAttachment->path . ' existing=' . $existingAttachment->path,
);
$this->assertSame(2, $todo->attachments()->count());
$this->assertTrue(
$todo->attachments()->pluck('original_name')->contains('completed.png')
);
$this->delete(route('business.systemization-todos.destroy', ['todo' => $todo->id]))
->assertRedirect();
$this->assertSoftDeleted('business_systemization_todos', ['id' => $todo->id]);
}
public function test_excel_import_preserves_hierarchy_and_does_not_overwrite_existing_rows(): void
{
$path = sys_get_temp_dir() . '/systemization-todos-' . uniqid() . '.xlsx';
$spreadsheet = new Spreadsheet();
$sheet = $spreadsheet->getActiveSheet();
$sheet->fromArray([
['类型', '内容', '待办事项(具体工作计划)', '总负责人', '责任人', '开始时间', '完成时间', '完成情况(放截图)', '备注'],
['收入', '平台收入核对', '核对淘系账单', '晨曦', '栎阳', '2026-10-20', '2026-10-21', '已完成', '首轮导入'],
[null, null, '核对拼多多账单', '晨曦', '秋香', '待定', '11月', '第一步已开始', null],
]);
(new Xlsx($spreadsheet))->save($path);
$spreadsheet->disconnectWorksheets();
try {
$this->loginAs()->post(route('business.systemization-todos.import'), [
'source' => new UploadedFile(
$path,
'10.19工作梳理.xlsx',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
null,
true,
),
])->assertRedirect();
$this->assertSame(2, SystemizationTodo::query()->count());
$first = SystemizationTodo::query()->orderBy('sort_order')->firstOrFail();
$second = SystemizationTodo::query()->orderByDesc('sort_order')->firstOrFail();
$this->assertSame('收入', $first->category);
$this->assertSame('平台收入核对', $first->section);
$this->assertSame(SystemizationTodo::STATUS_COMPLETED, $first->status);
$this->assertSame('收入', $second->category);
$this->assertSame('平台收入核对', $second->section);
$this->assertSame('待定', $second->start_note);
$this->assertSame('11月', $second->due_note);
$this->assertSame(SystemizationTodo::STATUS_IN_PROGRESS, $second->status);
$result = app(SystemizationTodoWorkbookImporter::class)
->import($path, (int) company_id(), user_id());
$this->assertSame(0, $result['created']);
$this->assertSame(2, $result['skipped']);
$this->assertSame(2, SystemizationTodo::query()->count());
$this->assertSame(0, SystemizationTodoAttachment::query()->count());
} finally {
@unlink($path);
}
}
private function pngUpload(string $name): UploadedFile
{
return UploadedFile::fake()->createWithContent(
$name,
base64_decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII='),
);
}
}